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 @@
-
+
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 @@
-
+
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');
+ });
+ });
+}