Cw 1247 improve tabs navigation (#2634)

* add dashboard page indicator

* refactor dashboard page indicator and UI

* refactor fade animation

* smoother UI transitions

* Improve accessibility for page indicator

* localize action names

* Update page indicator colors and blur effect

* fix page indicator layout

* Update Spanish translations for 'apps', 'history', and 'home'

* update dashboard labels

* changed settings page label

* Modify page_indicator.dart

* refactor page indicator animation

* review fixes

---------

Co-authored-by: Jaim3 <github@jaim3.com>
Co-authored-by: tuxsudo <tuxsudo@tux.pizza>
This commit is contained in:
Serhii
2025-11-14 15:34:54 +01:00
committed by GitHub
co-authored by Jaim3 tuxsudo
parent 40d2207732
commit cbf8e7d05d
44 changed files with 621 additions and 85 deletions
+16 -26
View File
@@ -3,6 +3,7 @@ import 'package:cake_wallet/entities/preferences_key.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_sidebar_wrapper.dart';
import 'package:cake_wallet/src/screens/dashboard/pages/cake_features_page.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/page_indicator.dart';
import 'package:cake_wallet/src/screens/wallet_connect/widgets/bottom_sheet/bottom_sheet_listener_widget.dart';
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
@@ -220,45 +221,34 @@ class _DashboardPageView extends BasePage {
child: Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
//new Expanded(
Observer(
builder: (context) {
return PageView.builder(
key: ValueKey('dashboard_page_view_key'),
key: const ValueKey('dashboard_page_view_key'),
controller: controller,
physics: const BouncingScrollPhysics(),
itemCount: pages.length,
itemBuilder: (context, index) => pages[index],
);
},
),
//),
Positioned(
child: Container(
alignment: Alignment.bottomCenter,
margin: EdgeInsets.only(bottom: 110),
child: Observer(
builder: (context) {
return Semantics(
button: false,
label: 'Page Indicator',
hint: 'Swipe to change page',
excludeSemantics: true,
child: SmoothPageIndicator(
controller: controller,
count: pages.length,
effect: ColorTransitionEffect(
spacing: 6.0,
radius: 6.0,
dotWidth: 6.0,
dotHeight: 6.0,
dotColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
activeDotColor: Theme.of(context).colorScheme.primary,
),
),
);
},
child: Semantics(
container: true,
label: 'Page indicator',
hint: 'Swipe left or right to change page, or double tap buttons below to navigate directly.',
child: ExcludeSemantics(
excluding: false,
child: PageIndicator(
controller: controller,
dashboardViewModel: dashboardViewModel,
),
),
),
),
),
NavigationDock(
dashboardViewModel: dashboardViewModel,
@@ -277,7 +267,7 @@ class _DashboardPageView extends BasePage {
if (dashboardViewModel.shouldShowMarketPlaceInDashboard) {
pages.add(
Semantics(
label: 'Cake ${S.of(context).features}',
label: S.of(context).apps,
child: CakeFeaturesPage(
dashboardViewModel: dashboardViewModel,
cakeFeaturesViewModel: getIt.get<CakeFeaturesViewModel>(),
@@ -288,7 +278,7 @@ class _DashboardPageView extends BasePage {
pages.add(Semantics(label: S.of(context).balance_page, child: balancePage));
pages.add(
Semantics(
label: S.of(context).settings_transactions,
label: S.of(context).history,
child: TransactionsPage(dashboardViewModel: dashboardViewModel),
),
);
@@ -28,7 +28,7 @@ class CakeFeaturesPage extends StatelessWidget {
Padding(
padding: EdgeInsets.only(left: 24, top: 16),
child: Text(
'Cake ${S.of(context).features}',
S.of(context).apps,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
@@ -19,7 +19,7 @@ class NavigationDock extends StatelessWidget {
child: Observer(
builder: (_) {
return Container(
height: 150,
height: 84,
alignment: Alignment.bottomCenter,
decoration: dashboardViewModel.settingsStore.backgroundImage.isEmpty
? BoxDecoration(
@@ -30,7 +30,6 @@ class NavigationDock extends StatelessWidget {
),
)
: null,
//color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50),
@@ -50,38 +49,51 @@ class NavigationDock extends StatelessWidget {
borderRadius: BorderRadius.circular(50.0),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8),
child: IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: MainActions.all
.where((element) => element.canShow?.call(dashboardViewModel) ?? true)
.map(
(action) => Expanded(
child: Semantics(
button: true,
enabled: (action.isEnabled?.call(dashboardViewModel) ?? true),
child: ActionButton(
key: action.key,
image: Image.asset(action.image,
height: 24,
width: 24,
color: Theme.of(context).colorScheme.primary),
title: action.name(context),
onClick: (action.isEnabled?.call(dashboardViewModel) ?? true)
? () async =>
await action.onTap(context, dashboardViewModel)
: null,
textColor: action.isEnabled?.call(dashboardViewModel) ?? true
? null
: Theme.of(context).colorScheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClipRect(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: MainActions.all
.where((element) =>
element.canShow?.call(dashboardViewModel) ??
true)
.map(
(action) => Expanded(
child: Semantics(
button: true,
enabled: (action.isEnabled
?.call(dashboardViewModel) ??
true),
child: ActionButton(
key: action.key,
image: Image.asset(
action.image,
height: 24,
width: 24,
color:
Theme.of(context).colorScheme.primary,
),
title: action.name(context),
onClick: (action.isEnabled
?.call(dashboardViewModel) ??
true)
? () async => await action.onTap(
context, dashboardViewModel)
: null,
textColor: action.isEnabled
?.call(dashboardViewModel) ??
true
? null
: Theme.of(context)
.colorScheme
.onSurface,
),
),
)
.toList(),
),
),
)
.toList(),
),
),
),
@@ -21,6 +21,11 @@ class ActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
if (route?.isNotEmpty ?? false) {
Navigator.of(context, rootNavigator: true).pushNamed(route!);
@@ -28,31 +33,32 @@ class ActionButton extends StatelessWidget {
onClick?.call();
}
},
child: Container(
color: Colors.transparent,
padding: EdgeInsets.only(top: 5, bottom: 4, left: 0, right: 0),
alignment: alignment,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
//mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle),
child: image,
),
SizedBox(height: 4),
Text(
title,
maxLines: 1,
overflow: TextOverflow.visible,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: textColor ?? Theme.of(context).colorScheme.onSurface),
textAlign: TextAlign.center,
)
],
child: SizedBox.expand(
child: Container(
color: Colors.transparent,
alignment: alignment,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
image,
const SizedBox(height: 4),
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: textColor ??
Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
),
),
);
@@ -23,7 +23,7 @@ class HeaderRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
S.of(context).transactions,
S.of(context).history,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
@@ -0,0 +1,326 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:cake_wallet/entities/page_indicator_actions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
class PageIndicator extends StatefulWidget {
const PageIndicator({
super.key,
required this.controller,
required this.dashboardViewModel,
});
final PageController controller;
final DashboardViewModel dashboardViewModel;
@override
State<PageIndicator> createState() => _PageIndicatorState();
}
class _PageIndicatorState extends State<PageIndicator> {
static const barHeight = 37.0;
static const iconWidth = 16.0;
static const iconHeight = 16.0;
static const iconSpacing = 5.0;
static const barHorizontalPadding = 4.0;
static const pillHorizontalPadding = 8.0;
static const edgePadding = 5.0;
static const barBorderRadius = 50.0;
static const pillBorderRadius = 50.0;
static const pageSwitchDuration = Duration(milliseconds: 300);
static const pillMoveDuration = Duration(milliseconds: 300);
static const pillResizeDuration = Duration(milliseconds: 200);
static const textStyle = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
);
late int selectedIndex;
late final VoidCallback _pageListener;
@override
void initState() {
super.initState();
selectedIndex = widget.controller.initialPage;
_pageListener = () {
final page = widget.controller.page;
if (page == null) return;
final rounded = page.round();
if (rounded != selectedIndex && mounted) {
setState(() {
selectedIndex = rounded;
});
}
};
widget.controller.addListener(_pageListener);
}
@override
void dispose() {
widget.controller.removeListener(_pageListener);
super.dispose();
}
void _onItemTap(int index) {
if (index == selectedIndex) return;
widget.controller.animateToPage(
index,
duration: pageSwitchDuration,
curve: Curves.easeOutCubic,
);
}
double _estimateItemWidthForAction(
BuildContext context, PageIndicatorActions action,
{Color? color}) {
final text = action.name(context);
final textPainter = TextPainter(
text: TextSpan(text: text, style: textStyle.copyWith(color: color)),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.of(context).textScaler,
)..layout();
return iconWidth +
iconSpacing +
textPainter.width +
pillHorizontalPadding * 2;
}
@override
Widget build(BuildContext context) {
final visibleActions = PageIndicatorActions.all.where((action) {
return action.isEnabled?.call(widget.dashboardViewModel) ?? true;
}).toList();
// Don't show indicator if less than 2 actions
if (visibleActions.length < 2) return const SizedBox.shrink();
selectedIndex = selectedIndex.clamp(0, visibleActions.length - 1);
// Theme setup
final theme = Theme.of(context);
final backgroundColor = theme.colorScheme.surfaceContainer.withAlpha(122);
final pillColor = theme.colorScheme.onSurface.withAlpha(30);
final activeColor = theme.colorScheme.onSurface;
final inactiveColor = theme.colorScheme.primary;
// Size calculations
final screenWidth = MediaQuery.of(context).size.width;
final isRTL = Directionality.of(context) == TextDirection.rtl;
final pillWidth = _estimateItemWidthForAction(
context,
visibleActions[selectedIndex],
color: activeColor,
);
final actionWidths = [
for (final action in visibleActions)
_estimateItemWidthForAction(context, action, color: inactiveColor)
];
final totalItemsWidth =
actionWidths.fold(0.0, (sum, width) => sum + width) +
barHorizontalPadding * 2;
final barWidth = math.min(totalItemsWidth, screenWidth * 0.95);
final count = visibleActions.length;
final firstLeft = edgePadding;
final secondLeft =
barWidth - (isRTL ? actionWidths.first : actionWidths[1]) - edgePadding;
final List<double> positions;
if (count == 2) {
positions = [firstLeft, secondLeft];
} else {
final lastLeft = barWidth -
(isRTL ? actionWidths.first : actionWidths.last) -
edgePadding;
final centerLeft = (isRTL
? actionWidths.last + edgePadding
: actionWidths.first + edgePadding);
positions = [firstLeft, centerLeft, lastLeft];
}
final left =
(isRTL ? positions.reversed.toList() : positions)[selectedIndex];
final currentAction = visibleActions[selectedIndex];
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(top: 10),
child: Container(
width: barWidth,
child: ClipRRect(
borderRadius: BorderRadius.circular(barBorderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
height: barHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(barBorderRadius),
),
child: Stack(
alignment: Alignment.center,
children: [
AnimatedPill(
left: left,
pillColor: pillColor,
currentAction: currentAction,
pillIconHeight: iconHeight,
pillIconWidth: iconWidth,
pillIconSpacing: iconSpacing,
pillBorderRadius: pillBorderRadius,
contentColor: activeColor,
estimateWidthForAction: pillWidth,
pillTextStyle: textStyle,
pillMoveDuration: pillMoveDuration,
pillResizeDuration: pillResizeDuration,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: barHorizontalPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (int i = 0; i < visibleActions.length; i++)
Semantics(
button: true,
label: visibleActions[i].name(context),
value: i == selectedIndex ? 'Selected' : null,
hint: 'Double tap to open ${visibleActions[i].name(context)} page',
onTap: () => _onItemTap(i),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _onItemTap(i),
child: Container(
height: barHeight,
width: i == selectedIndex
? pillWidth
: _estimateItemWidthForAction(
context, visibleActions[i]),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<Color?>(
duration: const Duration(
milliseconds: 300),
curve: Curves.easeOutCubic,
tween: ColorTween(
begin: inactiveColor,
end: i == selectedIndex
? activeColor
: inactiveColor,
),
builder: (context, color, _) =>
SvgPicture.asset(
visibleActions[i].image,
width: iconWidth,
height: iconHeight,
colorFilter: ColorFilter.mode(
color!, BlendMode.srcIn),
),
),
SizedBox(width: iconSpacing),
TweenAnimationBuilder<Color?>(
duration: const Duration(
milliseconds: 300),
curve: Curves.easeOutCubic,
tween: ColorTween(
begin: inactiveColor,
end: i == selectedIndex
? activeColor
: inactiveColor,
),
builder: (context, color, _) =>
Text(
visibleActions[i].name(context),
style: textStyle.copyWith(
color: color),
overflow: TextOverflow.fade,
softWrap: false,
),
),
],
),
),
),
),
],
),
)
],
)),
),
),
),
),
);
}
}
class AnimatedPill extends StatelessWidget {
const AnimatedPill({
super.key,
required this.left,
required this.pillColor,
required this.contentColor,
required this.pillIconHeight,
required this.pillIconWidth,
required this.pillBorderRadius,
required this.currentAction,
required this.estimateWidthForAction,
required this.pillIconSpacing,
required this.pillTextStyle,
required this.pillMoveDuration,
required this.pillResizeDuration,
});
final double left;
final Color pillColor;
final Color contentColor;
final double pillIconHeight;
final double pillIconWidth;
final double pillBorderRadius;
final PageIndicatorActions currentAction;
final double estimateWidthForAction;
final double pillIconSpacing;
final TextStyle pillTextStyle;
final Duration pillMoveDuration;
final Duration pillResizeDuration;
@override
Widget build(BuildContext context) {
return AnimatedPositioned(
duration: pillMoveDuration,
curve: Curves.easeOutCubic,
left: left,
top: 4,
bottom: 4,
child: AnimatedContainer(
duration: pillResizeDuration,
curve: Curves.easeOutCubic,
width: estimateWidthForAction,
decoration: BoxDecoration(
color: pillColor,
borderRadius: BorderRadius.circular(pillBorderRadius)),
clipBehavior: Clip.hardEdge,
alignment: Alignment.center,
),
);
}
}
@@ -37,7 +37,7 @@ class DisplaySettingsPage extends BasePage {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSwitcherCell(
title: S.of(context).show_market_place,
title: S.of(context).apps,
value: _displaySettingsViewModel.shouldShowMarketPlaceInDashboard,
onValueChange: (_, bool value) {
_displaySettingsViewModel.setShouldShowMarketPlaceInDashbaord(value);