feat: persist last active tab and preserve per-tab navigation state
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled

This commit is contained in:
2026-09-05 15:59:02 +03:00
parent af00cb8179
commit 28a63483da
19 changed files with 476 additions and 173 deletions
+150 -16
View File
@@ -7,13 +7,19 @@ import 'package:go_router/go_router.dart';
import '../../app/app.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart';
import '../../app/router.dart';
import '../../backend/backend.dart';
import '../../core/utils/result.dart';
class AppShell extends ConsumerStatefulWidget {
const AppShell({required this.child, super.key});
const AppShell({
required this.child,
this.navigationShell,
super.key,
});
final Widget child;
final StatefulNavigationShell? navigationShell;
static const destinations = [
_Destination('feed', 'Feed', Icons.dashboard_rounded, '/'),
@@ -33,6 +39,30 @@ class AppShell extends ConsumerStatefulWidget {
_Destination('settings', 'Settings', Icons.settings_rounded, '/settings'),
];
static int branchIndexForLocation(String location) {
if (location.startsWith('/settings')) return 7;
if (location.startsWith('/providers')) return 6;
if (location.startsWith('/artists')) return 5;
if (location.startsWith('/collections')) return 4;
if (location.startsWith('/viewed')) return 3;
if (location.startsWith('/favorites')) return 2;
if (location.startsWith('/search')) return 1;
return 0;
}
static String locationForBranchIndex(int index) {
return switch (index) {
1 => '/search',
2 => '/favorites',
3 => '/viewed',
4 => '/collections',
5 => '/artists',
6 => '/providers',
7 => '/settings',
_ => '/',
};
}
static List<_Destination> _visibleDestinations(
AppSettings settings,
bool hasArtists,
@@ -52,11 +82,70 @@ class _AppShellState extends ConsumerState<AppShell> {
final List<String> _tabHistory = [];
String? _currentPath;
bool _isBackNavigating = false;
bool _restoredTab = false;
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
_lifecycleListener = AppLifecycleListener(
onStateChange: (state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
_saveCurrentLocation();
}
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
final settings = ref.read(appSettingsProvider).value;
_maybeRestoreLastActiveTab(settings);
});
}
@override
void dispose() {
_lifecycleListener.dispose();
super.dispose();
}
void _saveCurrentLocation([String? location]) {
final loc = location ??
_currentPath ??
(widget.navigationShell != null
? AppShell.locationForBranchIndex(
widget.navigationShell!.currentIndex)
: '/');
ref.read(settingsServiceProvider).saveLastActiveLocation(loc);
}
void _maybeRestoreLastActiveTab(AppSettings? settings) {
if (_restoredTab || settings == null) return;
final lastLocation = settings.lastActiveLocation;
if (lastLocation.isNotEmpty && lastLocation != '/') {
final idx = AppShell.branchIndexForLocation(lastLocation);
if (idx > 0 && widget.navigationShell != null) {
_restoredTab = true;
widget.navigationShell!.goBranch(idx, initialLocation: false);
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final path = GoRouterState.of(context).uri.path;
_syncPath(path);
}
@override
void didUpdateWidget(covariant AppShell oldWidget) {
super.didUpdateWidget(oldWidget);
final path = GoRouterState.of(context).uri.path;
_syncPath(path);
}
void _syncPath(String path) {
if (_isBackNavigating) {
_isBackNavigating = false;
_currentPath = path;
@@ -74,10 +163,37 @@ class _AppShellState extends ConsumerState<AppShell> {
}
}
_currentPath = path;
_saveCurrentLocation(path);
}
void _onNavigate(String location) {
final targetIndex = AppShell.branchIndexForLocation(location);
if (widget.navigationShell != null) {
if (widget.navigationShell!.currentIndex == targetIndex) {
branchNavKeys[targetIndex]
.currentState
?.popUntil((route) => route.isFirst);
} else {
widget.navigationShell!.goBranch(
targetIndex,
initialLocation: false,
);
}
} else {
context.go(location);
}
_saveCurrentLocation(location);
}
bool _handleBack() {
if (Navigator.of(context).canPop()) {
final shell = widget.navigationShell;
if (shell != null) {
final currentBranchKey = branchNavKeys[shell.currentIndex];
if (currentBranchKey.currentState?.canPop() ?? false) {
currentBranchKey.currentState?.pop();
return true;
}
} else if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
return true;
}
@@ -90,13 +206,13 @@ class _AppShellState extends ConsumerState<AppShell> {
if (_tabHistory.isNotEmpty) {
final previous = _tabHistory.removeLast();
_isBackNavigating = true;
context.go(previous);
_onNavigate(previous);
return true;
}
if (currentPath != '/') {
if (currentPath != '/' && (shell == null || shell.currentIndex != 0)) {
_isBackNavigating = true;
context.go('/');
_onNavigate('/');
return true;
}
@@ -105,6 +221,12 @@ class _AppShellState extends ConsumerState<AppShell> {
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<AppSettings>>(appSettingsProvider, (prev, next) {
next.whenData((settings) {
_maybeRestoreLastActiveTab(settings);
});
});
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final artistConfigs =
@@ -147,7 +269,7 @@ class _AppShellState extends ConsumerState<AppShell> {
actions: {
_NavigateIntent: CallbackAction<_NavigateIntent>(
onInvoke: (intent) {
context.go(intent.location);
_onNavigate(intent.location);
return null;
},
),
@@ -164,11 +286,15 @@ class _AppShellState extends ConsumerState<AppShell> {
? _DesktopShell(
destinations: destinations,
ru: ru,
currentBranchIndex: widget.navigationShell?.currentIndex,
onNavigate: _onNavigate,
child: widget.child,
)
: _MobileShell(
destinations: destinations,
ru: ru,
currentBranchIndex: widget.navigationShell?.currentIndex,
onNavigate: _onNavigate,
child: widget.child,
),
),
@@ -183,10 +309,14 @@ class _DesktopShell extends StatefulWidget {
required this.child,
required this.destinations,
required this.ru,
required this.onNavigate,
this.currentBranchIndex,
});
final Widget child;
final List<_Destination> destinations;
final bool ru;
final ValueChanged<String> onNavigate;
final int? currentBranchIndex;
@override
State<_DesktopShell> createState() => _DesktopShellState();
@@ -198,10 +328,10 @@ class _DesktopShellState extends State<_DesktopShell> {
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final activeBranch = widget.currentBranchIndex ??
AppShell.branchIndexForLocation(location);
final selected = widget.destinations.indexWhere(
(item) => item.location == '/'
? location == '/'
: location.startsWith(item.location),
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
);
return Scaffold(
body: Row(
@@ -229,7 +359,7 @@ class _DesktopShellState extends State<_DesktopShell> {
selected: (selected < 0 ? 0 : selected) == index,
expanded: _hovered,
onTap: () {
context.go(widget.destinations[index].location);
widget.onNavigate(widget.destinations[index].location);
},
),
const Spacer(),
@@ -334,10 +464,14 @@ class _MobileShell extends StatelessWidget {
required this.child,
required this.destinations,
required this.ru,
required this.onNavigate,
this.currentBranchIndex,
});
final Widget child;
final List<_Destination> destinations;
final bool ru;
final ValueChanged<String> onNavigate;
final int? currentBranchIndex;
@override
Widget build(BuildContext context) {
@@ -347,10 +481,10 @@ class _MobileShell extends StatelessWidget {
.take(6)
.toList();
final location = GoRouterState.of(context).uri.path;
final activeBranch = currentBranchIndex ??
AppShell.branchIndexForLocation(location);
final selected = items.indexWhere(
(item) => item.location == '/'
? location == '/'
: location.startsWith(item.location),
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
);
final isKeyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
final bottomInset = isKeyboardOpen
@@ -368,13 +502,13 @@ class _MobileShell extends StatelessWidget {
),
child: child,
),
floatingActionButton: (location == '/settings' || isKeyboardOpen)
floatingActionButton: (activeBranch == 7 || isKeyboardOpen)
? null
: Padding(
padding: const EdgeInsets.only(bottom: 0, right: 2),
child: _LiquidGlassSettingsButton(
ru: ru,
onTap: () => context.go('/settings'),
onTap: () => onNavigate('/settings'),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
@@ -383,7 +517,7 @@ class _MobileShell extends StatelessWidget {
: _LiquidGlassBottomBar(
selectedIndex: selected < 0 ? 0 : selected,
onDestinationSelected: (index) =>
context.go(items[index].location),
onNavigate(items[index].location),
items: items,
ru: ru,
),