Skip to content

Commit 439a140

Browse files
committed
feat: Integrate persistent_bottom_nav_bar for improved navigation and UI consistency across main and dashboard screens
1 parent 0171155 commit 439a140

6 files changed

Lines changed: 116 additions & 73 deletions

File tree

client/lib/devices/borneo/lyfi/views/dashboard/dashboard_chart.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ class DashboardChart extends StatelessWidget {
2828
child: Column(
2929
mainAxisAlignment: MainAxisAlignment.center,
3030
children: [
31-
Icon(Icons.wifi_off, size: 64, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)),
32-
const SizedBox(height: 16),
31+
Icon(Icons.wifi_off, size: 48, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)),
32+
const SizedBox(height: 8),
3333
Text(
3434
context.translate('Device Offline'),
35-
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
35+
style: Theme.of(context).textTheme.titleLarge?.copyWith(
3636
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
3737
),
3838
),

client/lib/devices/borneo/lyfi/views/dashboard/dashboard_dimming_tile.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_gettext/flutter_gettext/context_ext.dart';
3+
import 'package:persistent_bottom_nav_bar/persistent_bottom_nav_bar.dart';
34
import 'package:provider/provider.dart';
45
import '../../view_models/lyfi_view_model.dart';
56
import '../dimming_screen.dart';
@@ -37,11 +38,11 @@ class DashboardDimmingTile extends StatelessWidget {
3738
await vm.onDimmingReady();
3839

3940
if (context.mounted) {
40-
await Navigator.push(
41+
await PersistentNavBarNavigator.pushNewScreen(
4142
context,
42-
MaterialPageRoute(
43-
builder: (_) => ChangeNotifierProvider.value(value: vm, child: const DimmingScreen()),
44-
),
43+
screen: ChangeNotifierProvider.value(value: vm, child: const DimmingScreen()),
44+
withNavBar: false,
45+
pageTransitionAnimation: PageTransitionAnimation.slideRight,
4546
);
4647
}
4748
}

client/lib/main/views/main_screen.dart

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
1616
import 'package:flutter_gettext/flutter_gettext/context_ext.dart';
1717
import 'package:flutter_gettext/flutter_gettext/gettext_localizations.dart';
1818
import 'package:logger/logger.dart';
19+
import 'package:persistent_bottom_nav_bar/persistent_bottom_nav_bar.dart';
1920
import 'package:provider/provider.dart';
2021
import 'package:flutter_native_splash/flutter_native_splash.dart';
2122

@@ -27,6 +28,7 @@ import '../../features/devices/views/devices_screen.dart';
2728
import '../../features/my/views/my_screen.dart';
2829

2930
import '../view_models/main_view_model.dart';
31+
import '../../routes/route_manager.dart';
3032

3133
enum PlusMenuIndexes { addScene, addGroup, addDevice }
3234

@@ -143,86 +145,101 @@ class MainScreen extends StatefulWidget {
143145
}
144146

145147
class _MainScreenState extends State<MainScreen> {
146-
late final PageController _pageController;
148+
// controller for persistent_bottom_nav_bar package
149+
late final PersistentTabController _persistentController;
150+
151+
// One GlobalKey per tab so we can interrogate each tab's Navigator state
152+
// and manually pop sub-pages when the Android back button is pressed.
153+
final List<GlobalKey<NavigatorState>> _tabNavKeys = List.generate(3, (_) => GlobalKey<NavigatorState>());
147154

148155
@override
149156
void initState() {
150157
super.initState();
151-
_pageController = PageController(initialPage: 0);
158+
_persistentController = PersistentTabController(initialIndex: 0);
152159
}
153160

154161
@override
155162
void dispose() {
156-
_pageController.dispose();
157163
super.dispose();
158164
}
159165

160166
Widget buildScaffold(BuildContext context) {
161167
final mainVM = context.read<MainViewModel>();
168+
final routeManager = context.read<RouteManager>();
169+
170+
List<Widget> buildScreens() => const [
171+
ProvideScenesViewModel(child: ScenesScreen(key: ValueKey('scenes'))),
172+
DevicesScreen(key: ValueKey('devices')),
173+
MyScreen(key: ValueKey('my')),
174+
];
175+
176+
// Each tab needs its own RouteAndNavigatorSettings so that
177+
// Navigator.of(context).pushNamed(...) calls inside tabs can resolve
178+
// named routes (device detail pages, discovery screen, etc.).
179+
// We also pass individual GlobalKeys so the outer PopScope can check
180+
// whether a tab's navigator can pop before running the exit logic.
181+
RouteAndNavigatorSettings tabNavSettings(int index) =>
182+
RouteAndNavigatorSettings(onGenerateRoute: routeManager.onGenerateRoute, navigatorKey: _tabNavKeys[index]);
183+
184+
List<PersistentBottomNavBarItem> navBarItems() => [
185+
PersistentBottomNavBarItem(
186+
icon: const Icon(Icons.house),
187+
inactiveIcon: const Icon(Icons.house_outlined),
188+
title: context.translate('Scenes'),
189+
activeColorPrimary: Theme.of(context).colorScheme.primary,
190+
inactiveColorPrimary: Theme.of(context).colorScheme.onSurface,
191+
routeAndNavigatorSettings: tabNavSettings(0),
192+
),
193+
PersistentBottomNavBarItem(
194+
icon: const Icon(Icons.device_hub),
195+
inactiveIcon: const Icon(Icons.device_hub_outlined),
196+
title: context.translate('Devices'),
197+
activeColorPrimary: Theme.of(context).colorScheme.primary,
198+
inactiveColorPrimary: Theme.of(context).colorScheme.onSurface,
199+
routeAndNavigatorSettings: tabNavSettings(1),
200+
),
201+
PersistentBottomNavBarItem(
202+
icon: const Icon(Icons.person),
203+
inactiveIcon: const Icon(Icons.person_outline),
204+
title: context.translate('My'),
205+
activeColorPrimary: Theme.of(context).colorScheme.primary,
206+
inactiveColorPrimary: Theme.of(context).colorScheme.onSurface,
207+
routeAndNavigatorSettings: tabNavSettings(2),
208+
),
209+
];
210+
162211
return Selector<MainViewModel, TabIndices>(
163212
selector: (context, vm) => vm.currentTabIndex,
164213
builder: (context, tabIndex, child) {
165-
WidgetsBinding.instance.addPostFrameCallback((_) {
166-
if (_pageController.hasClients) {
167-
final current = _pageController.page?.round() ?? _pageController.initialPage;
168-
if (current != tabIndex.index) {
169-
_pageController.animateToPage(
170-
tabIndex.index,
171-
duration: const Duration(milliseconds: 300),
172-
curve: Curves.easeOutCubic,
173-
);
174-
}
175-
}
176-
});
214+
// keep controller in sync with view model state
215+
if (_persistentController.index != tabIndex.index) {
216+
_persistentController.jumpToTab(tabIndex.index);
217+
}
177218

178219
return AnnotatedRegion<SystemUiOverlayStyle>(
179220
value: const SystemUiOverlayStyle(
180221
statusBarColor: Colors.transparent,
181222
statusBarIconBrightness: Brightness.dark,
182223
),
183-
child: Scaffold(
184-
appBar: null,
185-
body: PageView(
186-
controller: _pageController,
187-
// Enable user swipe to switch tabs
188-
physics: const PageScrollPhysics(),
189-
onPageChanged: (index) {
190-
if (index != tabIndex.index) {
191-
mainVM.setIndex(TabIndices.values[index]);
192-
}
193-
},
194-
children: const [
195-
ProvideScenesViewModel(child: ScenesScreen(key: ValueKey('scenes'))),
196-
DevicesScreen(key: ValueKey('devices')),
197-
MyScreen(key: ValueKey('my')),
198-
],
199-
),
200-
201-
bottomNavigationBar: BottomNavigationBar(
202-
currentIndex: tabIndex.index,
203-
onTap: (index) {
204-
if (index != tabIndex.index) {
205-
_pageController.jumpToPage(index);
206-
}
207-
},
208-
items: [
209-
BottomNavigationBarItem(
210-
icon: const Icon(Icons.house_outlined),
211-
activeIcon: const Icon(Icons.house),
212-
label: context.translate('Scenes'),
213-
),
214-
BottomNavigationBarItem(
215-
icon: const Icon(Icons.device_hub_outlined),
216-
activeIcon: const Icon(Icons.device_hub),
217-
label: context.translate('Devices'),
218-
),
219-
BottomNavigationBarItem(
220-
icon: const Icon(Icons.person_outline),
221-
activeIcon: const Icon(Icons.person),
222-
label: context.translate('My'),
223-
),
224-
],
225-
),
224+
child: PersistentTabView(
225+
context,
226+
controller: _persistentController,
227+
screens: buildScreens(),
228+
items: navBarItems(),
229+
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
230+
//Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
231+
//Theme.of(context).colorScheme.surfaceContainerHighest,
232+
// Back-button handling is fully managed by the outer PopScope
233+
// with per-tab navigator-key checks, so we disable the built-in
234+
// handler to avoid double-handling.
235+
handleAndroidBackButtonPress: false,
236+
resizeToAvoidBottomInset: true,
237+
onItemSelected: (index) {
238+
if (index != tabIndex.index) {
239+
mainVM.setIndex(TabIndices.values[index]);
240+
}
241+
},
242+
navBarStyle: NavBarStyle.style9,
226243
),
227244
);
228245
},
@@ -304,6 +321,15 @@ class _MainScreenState extends State<MainScreen> {
304321
onPopInvokedWithResult: (didPop, _) async {
305322
if (didPop) return;
306323

324+
// If the current tab's inner Navigator has pages above root,
325+
// pop those first – no exit prompt in that case.
326+
final tabNavState = _tabNavKeys[_persistentController.index].currentState;
327+
if (tabNavState != null && tabNavState.canPop()) {
328+
tabNavState.pop();
329+
return;
330+
}
331+
332+
// We're at the navigation root: apply double-back-to-exit.
307333
final shouldPop = await vm.handleWillPop();
308334
if (!shouldPop) {
309335
if (context.mounted) {
@@ -313,11 +339,7 @@ class _MainScreenState extends State<MainScreen> {
313339
).showInfo(context.translate('Press back again to exit'));
314340
}
315341
} else if (context.mounted) {
316-
if (Navigator.of(context).canPop()) {
317-
Navigator.of(context).pop();
318-
} else {
319-
SystemNavigator.pop();
320-
}
342+
SystemNavigator.pop();
321343
}
322344
},
323345
child: Selector<MainViewModel, bool>(

client/packages/borneo_kernel/lib/kernel.dart

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,20 @@ final class DefaultKernel implements IKernel {
180180
} else {
181181
final count = (_backoffCount[id] ?? 0) + 1;
182182
_backoffCount[id] = count;
183-
// interval = min(base * 2^count, maxBackoff)
184-
final wait = baseInterval * (1 << (count - 1));
185-
_nextBindAttempt[id] = now.add(wait < _maxBackoff ? wait : _maxBackoff);
183+
// interval = min(base * 2^(count-1), maxBackoff)
184+
// protect against integer overflow or resulting duration overflowing
185+
// the valid DateTime range. 1 << (count-1) may overflow when count is
186+
// huge, so clamp the shift amount.
187+
const int maxShift = 62; // safe even on 64-bit
188+
final int shift = (count - 1).clamp(0, maxShift);
189+
final Duration candidate = baseInterval * (1 << shift);
190+
final Duration wait = candidate < _maxBackoff ? candidate : _maxBackoff;
191+
try {
192+
_nextBindAttempt[id] = now.add(wait);
193+
} on RangeError catch (_) {
194+
// if adding still produces an invalid DateTime, fall back to maxBackoff
195+
_nextBindAttempt[id] = now.add(_maxBackoff);
196+
}
186197
}
187198
}
188199
});

client/pubspec.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,14 @@ packages:
12581258
url: "https://pub.dev"
12591259
source: hosted
12601260
version: "0.2.1"
1261+
persistent_bottom_nav_bar:
1262+
dependency: "direct main"
1263+
description:
1264+
name: persistent_bottom_nav_bar
1265+
sha256: "6aa9b97ced1abd92c90cedd1997d34ea0b35c3ded762ac6063baccc299b0c4c5"
1266+
url: "https://pub.dev"
1267+
source: hosted
1268+
version: "6.2.1"
12611269
petitparser:
12621270
dependency: transitive
12631271
description:

client/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ dependencies:
113113
window_size: ^0.1.0
114114
screen_corner_radius: ^3.0.0
115115
community_material_icon: ^5.9.55
116+
persistent_bottom_nav_bar: ^6.2.0
116117

117118

118119
dev_dependencies:

0 commit comments

Comments
 (0)