From 28a63483da1bf6819297f59c2e0c8f8a2c99121b Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 15:59:02 +0300 Subject: [PATCH] feat: persist last active tab and preserve per-tab navigation state --- README.md | 2 +- README_RU.md | 2 +- lib/app/app_navigator.dart | 41 +++ lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 9 + lib/app/router.dart | 251 +++++++++++------- lib/backend/services/settings_service.dart | 16 ++ .../presentation/artist_posts_screen.dart | 12 +- .../artists/presentation/artists_screen.dart | 11 +- .../collection_details_screen.dart | 11 +- .../presentation/favorites_screen.dart | 11 +- .../feed/presentation/feed_screen.dart | 13 +- .../presentation/post_details_screen.dart | 14 +- .../presentation/similar_posts_screen.dart | 8 +- .../presentation/hidden_posts_screen.dart | 8 +- .../viewed/presentation/viewed_screen.dart | 21 +- lib/shared/widgets/app_shell.dart | 166 ++++++++++-- pubspec.yaml | 2 +- .../tab_navigation_preservation_test.dart | 47 ++++ 19 files changed, 476 insertions(+), 173 deletions(-) create mode 100644 lib/app/app_navigator.dart create mode 100644 test/features/tab_navigation_preservation_test.dart diff --git a/README.md b/README.md index 099b584..a73f6aa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Version 3.5.4 + Version 3.5.5 Flutter Platforms Wiki diff --git a/README_RU.md b/README_RU.md index 064980b..5d5bc08 100644 --- a/README_RU.md +++ b/README_RU.md @@ -14,7 +14,7 @@

- Version 3.5.4 + Version 3.5.5 Flutter Platforms Wiki diff --git a/lib/app/app_navigator.dart b/lib/app/app_navigator.dart new file mode 100644 index 0000000..dc0db24 --- /dev/null +++ b/lib/app/app_navigator.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../backend/models/post.dart'; +import '../features/post/presentation/post_details_screen.dart'; +import '../features/post/presentation/similar_posts_screen.dart'; + +class AppNavigator { + const AppNavigator._(); + + static void openPost( + BuildContext context, { + required Post post, + List? postsList, + }) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => PostDetailsScreen( + providerId: post.providerId, + postId: post.id, + initialPost: post, + postsList: postsList, + ), + ), + ); + } + + static void openSimilarPosts( + BuildContext context, { + required Post post, + }) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => SimilarPostsScreen( + providerId: post.providerId, + postId: post.id, + initialPost: post, + ), + ), + ); + } +} diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index da6cdf2..0c32247 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.5.4'; -const appBuildNumber = 80; +const appDisplayVersion = '3.5.5'; +const appBuildNumber = 81; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index ee65aff..9ad918c 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const prismaChangelog = [ + PrismaChange( + version: '3.5.5', + title: 'Сохранение последней вкладки и состояния навигации между разделами', + bullets: [ + 'Сохранение последней вкладки: приложение запоминает активный раздел при выходе или закрытии и автоматически открывает его при следующем запуске.', + 'Независимая история и сохранение места: при переключении между вкладками состояние каждого раздела полностью сохраняется. Например, если открыть публикацию или карточку у артиста и переключиться в поиск или ленту, при возврате обратно вы останетесь ровно на том же посте.', + 'Бесшовные переходы между ветками навигации: улучшена обработка системной кнопки «Назад», переход по разделам стал еще быстрее и плавнее.', + ], + ), PrismaChange( version: '3.5.4', title: 'Исправление распознавания MP4-видео и отключение устаревших сервисов Kemono/Coomer', diff --git a/lib/app/router.dart b/lib/app/router.dart index 2b5e9d9..97fb1ee 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -21,116 +21,171 @@ import '../features/settings/presentation/settings_screen.dart'; import '../features/viewed/presentation/viewed_screen.dart'; import '../shared/widgets/app_shell.dart'; +final branchNavKeys = List.generate(8, (_) => GlobalKey()); + final appRouterProvider = Provider((ref) { return GoRouter( initialLocation: '/', routes: [ - ShellRoute( - builder: (context, state, child) => AppShell(child: child), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => AppShell( + navigationShell: navigationShell, + child: navigationShell, + ), + branches: [ + StatefulShellBranch( + navigatorKey: branchNavKeys[0], + routes: [ + GoRoute( + path: '/', + pageBuilder: (context, state) => _transitionPage( + state, + child: FeedScreen(initialQuery: state.uri.queryParameters['q']), + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[1], + routes: [ + GoRoute( + path: '/search', + builder: (context, state) => const SearchScreen(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[2], + routes: [ + GoRoute( + path: '/favorites', + builder: (context, state) => const FavoritesScreen(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[3], + routes: [ + GoRoute( + path: '/viewed', + builder: (context, state) => const ViewedScreen(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[4], + routes: [ + GoRoute( + path: '/collections', + builder: (context, state) => const CollectionsScreen(), + routes: [ + GoRoute( + path: ':collectionId', + builder: (context, state) => CollectionDetailsScreen( + collectionId: state.pathParameters['collectionId']!, + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[5], + routes: [ + GoRoute( + path: '/artists', + builder: (context, state) => const ArtistsScreen(), + routes: [ + GoRoute( + path: ':providerId/:service/:artistId', + builder: (context, state) => ArtistPostsScreen( + providerId: state.pathParameters['providerId']!, + service: state.pathParameters['service']!, + artistId: state.pathParameters['artistId']!, + artistName: state.uri.queryParameters['name'] ?? + state.pathParameters['artistId']!, + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[6], + routes: [ + GoRoute( + path: '/providers', + builder: (context, state) => const ProvidersScreen(), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) => ProviderFormScreen( + initialConfig: state.extra is ContentProviderConfig + ? state.extra! as ContentProviderConfig + : null, + ), + ), + GoRoute( + path: 'check', + builder: (context, state) => const ProviderCheckScreen(), + ), + ], + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavKeys[7], + routes: [ + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + routes: [ + GoRoute( + path: 'hidden', + builder: (context, state) => const HiddenPostsScreen(), + ), + GoRoute( + path: 'cache', + builder: (context, state) => const CacheManagerScreen(), + ), + ], + ), + ], + ), + ], + ), + GoRoute( + path: '/post/:providerId/:postId', + builder: (context, state) { + final extra = state.extra; + final Post? initialPost; + final List? postsList; + if (extra is PostNavigationContext) { + initialPost = extra.currentPost; + postsList = extra.posts; + } else if (extra is Post) { + initialPost = extra; + postsList = null; + } else { + initialPost = null; + postsList = null; + } + return PostDetailsScreen( + providerId: state.pathParameters['providerId']!, + postId: state.pathParameters['postId']!, + initialPost: initialPost, + postsList: postsList, + ); + }, routes: [ GoRoute( - path: '/', - pageBuilder: (context, state) => _transitionPage( - state, - child: FeedScreen(initialQuery: state.uri.queryParameters['q']), - ), - ), - GoRoute( - path: '/search', - builder: (context, state) => const SearchScreen(), - ), - GoRoute( - path: '/post/:providerId/:postId', - builder: (context, state) { - final extra = state.extra; - final Post? initialPost; - final List? postsList; - if (extra is PostNavigationContext) { - initialPost = extra.currentPost; - postsList = extra.posts; - } else if (extra is Post) { - initialPost = extra; - postsList = null; - } else { - initialPost = null; - postsList = null; - } - return PostDetailsScreen( - providerId: state.pathParameters['providerId']!, - postId: state.pathParameters['postId']!, - initialPost: initialPost, - postsList: postsList, - ); - }, - ), - GoRoute( - path: '/post/:providerId/:postId/similar', + path: 'similar', builder: (context, state) => SimilarPostsScreen( providerId: state.pathParameters['providerId']!, postId: state.pathParameters['postId']!, initialPost: state.extra is Post ? state.extra! as Post : null, ), ), - GoRoute( - path: '/favorites', - builder: (context, state) => const FavoritesScreen(), - ), - GoRoute( - path: '/viewed', - builder: (context, state) => const ViewedScreen(), - ), - GoRoute( - path: '/collections', - builder: (context, state) => const CollectionsScreen(), - ), - GoRoute( - path: '/collections/:collectionId', - builder: (context, state) => CollectionDetailsScreen( - collectionId: state.pathParameters['collectionId']!, - ), - ), - GoRoute( - path: '/artists', - builder: (context, state) => const ArtistsScreen(), - ), - GoRoute( - path: '/artists/:providerId/:service/:artistId', - builder: (context, state) => ArtistPostsScreen( - providerId: state.pathParameters['providerId']!, - service: state.pathParameters['service']!, - artistId: state.pathParameters['artistId']!, - artistName: state.uri.queryParameters['name'] ?? - state.pathParameters['artistId']!, - ), - ), - GoRoute( - path: '/providers', - builder: (context, state) => const ProvidersScreen(), - ), - GoRoute( - path: '/providers/new', - builder: (context, state) => ProviderFormScreen( - initialConfig: state.extra is ContentProviderConfig - ? state.extra! as ContentProviderConfig - : null, - ), - ), - GoRoute( - path: '/providers/check', - builder: (context, state) => const ProviderCheckScreen(), - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsScreen(), - ), - GoRoute( - path: '/settings/hidden', - builder: (context, state) => const HiddenPostsScreen(), - ), - GoRoute( - path: '/settings/cache', - builder: (context, state) => const CacheManagerScreen(), - ), ], ), ], diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 75ab1c6..55eee6f 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -64,8 +64,10 @@ class AppSettings { this.downloadPathTemplate = '{Artist}/{ID}', this.searchHistoryLimit = 500, this.tagCacheLimit = 5000, + this.lastActiveLocation = '/', }); + final String lastActiveLocation; final List favoriteArtists; final List pawchiveAccounts; final bool pawchiveBidirectionalSync; @@ -165,6 +167,7 @@ class AppSettings { lastFeedRating: null, searchHistoryLimit: 500, tagCacheLimit: 5000, + lastActiveLocation: '/', ); AppSettings copyWith({ @@ -220,8 +223,10 @@ class AppSettings { String? downloadPathTemplate, int? searchHistoryLimit, int? tagCacheLimit, + String? lastActiveLocation, }) { return AppSettings( + lastActiveLocation: lastActiveLocation ?? this.lastActiveLocation, favoriteArtists: favoriteArtists ?? this.favoriteArtists, pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts, pawchiveBidirectionalSync: @@ -336,6 +341,7 @@ class AppSettings { 'downloadPathTemplate': downloadPathTemplate, 'searchHistoryLimit': searchHistoryLimit, 'tagCacheLimit': tagCacheLimit, + 'lastActiveLocation': lastActiveLocation, }; factory AppSettings.fromJson(Map json) => AppSettings( @@ -455,6 +461,8 @@ class AppSettings { defaults.searchHistoryLimit, tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ?? defaults.tagCacheLimit, + lastActiveLocation: (json['lastActiveLocation'] as String?) ?? + defaults.lastActiveLocation, ); List get parsedPawchiveAccounts { @@ -545,6 +553,14 @@ class SettingsService { return updateSettings(settings.copyWith(nsfwEnabled: enabled)); } + Future> saveLastActiveLocation(String location) async { + final result = await getSettings(); + if (result is Error) return Error(result.failure); + final settings = (result as Success).data; + if (settings.lastActiveLocation == location) return const Success(null); + return updateSettings(settings.copyWith(lastActiveLocation: location)); + } + Future> saveCacheSettings({ required int ttlHours, required int maxItems, diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 5cc251f..9b3403a 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -3,10 +3,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; @@ -791,12 +791,10 @@ class _ArtistPostsScreenState extends ConsumerState { settings.mediaQualityMode), favoriteKeys: favoriteKeys, viewedKeys: viewedKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: displayedPosts, - ), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: displayedPosts, ), onFavorite: (post) async { if (favoriteKeys.contains(post.cacheKey)) { diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index 6062cd1..ff5ef92 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; @@ -1574,12 +1575,10 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget { final isVideo = MediaUrlSelector.isVideo(post); return InkWell( borderRadius: BorderRadius.circular(14), - onTap: () => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: posts, - ), + onTap: () => AppNavigator.openPost( + context, + post: post, + postsList: posts, ), child: Container( decoration: BoxDecoration( diff --git a/lib/features/collections/presentation/collection_details_screen.dart b/lib/features/collections/presentation/collection_details_screen.dart index f8dcda0..6af442a 100644 --- a/lib/features/collections/presentation/collection_details_screen.dart +++ b/lib/features/collections/presentation/collection_details_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; @@ -167,12 +168,10 @@ class CollectionDetailsScreen extends ConsumerWidget { mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode), favoriteKeys: favoriteKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: items, - ), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: items, ), onFavorite: (post) async { if (favoriteKeys.contains(post.cacheKey)) { diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index fc7cce5..16013e2 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; @@ -970,12 +971,10 @@ class _FavoritesScreenState extends ConsumerState { mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode), favoriteKeys: posts.map((post) => post.cacheKey).toSet(), downloadedKeys: downloadedKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: posts, - ), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: posts, ), onFavorite: (post) => ref.read(favoritesControllerProvider.notifier).remove(post), diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index d1b23ca..2ac2573 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -8,6 +8,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; @@ -331,12 +332,10 @@ class _FeedScreenState extends ConsumerState selectionMode: _selectionMode, selectedKeys: _selectedKeys, gridMode: settings.gridMode, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: state.posts, - ), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: state.posts, ), onPreview: (post) => _showPreview(context, post), onToggleSelected: (post) => _toggleSelected(post), @@ -392,7 +391,7 @@ class _FeedScreenState extends ConsumerState void _openRandom(List posts) { if (posts.isEmpty) return; final post = posts[Random().nextInt(posts.length)]; - context.push('/post/${post.providerId}/${post.id}', extra: post); + AppNavigator.openPost(context, post: post, postsList: posts); } Future _applySearchQuery(String query) async { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index dee46ce..0aa8858 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/app_strings.dart'; import '../../../app/motion.dart'; import '../../../app/responsive.dart'; @@ -872,7 +873,7 @@ class PostDetailsScreen extends ConsumerWidget { } void _openSimilar(BuildContext context, WidgetRef ref, Post post) { - context.push('/post/${post.providerId}/${post.id}/similar', extra: post); + AppNavigator.openSimilarPosts(context, post: post); } Future _openSourcePage(WidgetRef ref, Post post) async { @@ -888,7 +889,16 @@ class PostDetailsScreen extends ConsumerWidget { } void _replacePost(BuildContext context, Post post) { - context.replace('/post/${post.providerId}/${post.id}', extra: post); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (ctx) => PostDetailsScreen( + providerId: post.providerId, + postId: post.id, + initialPost: post, + postsList: postsList, + ), + ), + ); } void _close(BuildContext context) { diff --git a/lib/features/post/presentation/similar_posts_screen.dart b/lib/features/post/presentation/similar_posts_screen.dart index a018257..9205d63 100644 --- a/lib/features/post/presentation/similar_posts_screen.dart +++ b/lib/features/post/presentation/similar_posts_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; @@ -98,9 +98,9 @@ class SimilarPostsScreen extends ConsumerWidget { MediaQualityMode.fromName(settings.mediaQualityMode), favoriteKeys: favorites, viewedKeys: viewed, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: post, + onOpen: (post) => AppNavigator.openPost( + context, + post: post, ), onFavorite: (post) => _toggleFavorite(ref, post, favorites), diff --git a/lib/features/settings/presentation/hidden_posts_screen.dart b/lib/features/settings/presentation/hidden_posts_screen.dart index 9e4365b..a622c5a 100644 --- a/lib/features/settings/presentation/hidden_posts_screen.dart +++ b/lib/features/settings/presentation/hidden_posts_screen.dart @@ -1,9 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; @@ -62,9 +62,9 @@ class HiddenPostsScreen extends ConsumerWidget { item: item, onOpen: item.post == null ? null - : () => context.push( - '/post/${item.post!.providerId}/${item.post!.id}', - extra: item.post, + : () => AppNavigator.openPost( + context, + post: item.post!, ), onRestore: () async { await ref diff --git a/lib/features/viewed/presentation/viewed_screen.dart b/lib/features/viewed/presentation/viewed_screen.dart index 55ddc00..dee336a 100644 --- a/lib/features/viewed/presentation/viewed_screen.dart +++ b/lib/features/viewed/presentation/viewed_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/app.dart'; +import '../../../app/app_navigator.dart'; import '../../../app/responsive.dart'; import '../../../backend/backend.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; @@ -142,12 +143,10 @@ class _ViewedScreenState extends ConsumerState { mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode), favoriteKeys: favoriteKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: allPosts, - ), + onOpen: (post) => AppNavigator.openPost( + context, + post: post, + postsList: allPosts, ), onFavorite: (post) => _toggleFavorite( ref, @@ -260,12 +259,10 @@ class _ViewedScreenState extends ConsumerState { isFavorite: favoriteKeys.contains(item.post.cacheKey), isRu: isRu, onOpen: () { - context.push( - '/post/${item.post.providerId}/${item.post.id}', - extra: PostNavigationContext( - currentPost: item.post, - posts: allPosts, - ), + AppNavigator.openPost( + context, + post: item.post, + postsList: allPosts, ); }, onFavorite: () => _toggleFavorite( diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index f166680..81d0cac 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -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 { final List _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 { } } _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 { 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 { @override Widget build(BuildContext context) { + ref.listen>(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 { actions: { _NavigateIntent: CallbackAction<_NavigateIntent>( onInvoke: (intent) { - context.go(intent.location); + _onNavigate(intent.location); return null; }, ), @@ -164,11 +286,15 @@ class _AppShellState extends ConsumerState { ? _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 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 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, ), diff --git a/pubspec.yaml b/pubspec.yaml index 6ada25c..c4c46ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.5.4+80 +version: 3.5.5+81 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/features/tab_navigation_preservation_test.dart b/test/features/tab_navigation_preservation_test.dart new file mode 100644 index 0000000..aa5df19 --- /dev/null +++ b/test/features/tab_navigation_preservation_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/backend.dart'; +import 'package:gel_rule_app/shared/widgets/app_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Tab Navigation & State Preservation Tests', () { + test('AppShell branchIndexForLocation and locationForBranchIndex map correctly', () { + expect(AppShell.branchIndexForLocation('/'), 0); + expect(AppShell.branchIndexForLocation('/?q=test'), 0); + expect(AppShell.branchIndexForLocation('/search'), 1); + expect(AppShell.branchIndexForLocation('/favorites'), 2); + expect(AppShell.branchIndexForLocation('/viewed'), 3); + expect(AppShell.branchIndexForLocation('/collections'), 4); + expect(AppShell.branchIndexForLocation('/collections/123'), 4); + expect(AppShell.branchIndexForLocation('/artists'), 5); + expect(AppShell.branchIndexForLocation('/artists/pawchive/patreon/99'), 5); + expect(AppShell.branchIndexForLocation('/providers'), 6); + expect(AppShell.branchIndexForLocation('/settings'), 7); + expect(AppShell.branchIndexForLocation('/settings/hidden'), 7); + + expect(AppShell.locationForBranchIndex(0), '/'); + expect(AppShell.locationForBranchIndex(1), '/search'); + expect(AppShell.locationForBranchIndex(2), '/favorites'); + expect(AppShell.locationForBranchIndex(3), '/viewed'); + expect(AppShell.locationForBranchIndex(4), '/collections'); + expect(AppShell.locationForBranchIndex(5), '/artists'); + expect(AppShell.locationForBranchIndex(6), '/providers'); + expect(AppShell.locationForBranchIndex(7), '/settings'); + }); + + test('AppSettings preserves lastActiveLocation in JSON serialization', () { + const defaults = AppSettings.defaults; + expect(defaults.lastActiveLocation, '/'); + + final updated = defaults.copyWith(lastActiveLocation: '/artists/pawchive/patreon/12345'); + expect(updated.lastActiveLocation, '/artists/pawchive/patreon/12345'); + + final json = updated.toJson(); + expect(json['lastActiveLocation'], '/artists/pawchive/patreon/12345'); + + final restored = AppSettings.fromJson(json); + expect(restored.lastActiveLocation, '/artists/pawchive/patreon/12345'); + }); + }); +}