Skip to content

Commit edfe83a

Browse files
committed
fix: synchronously handle popping page events
The callback of `BackButtonListener` is called before `RootLocationCubit` update its state. This commit moves all popping page process to the cubit listener to handle popping events synchronously. Now the listener is purely an app wide interceptor only redirect popping events without handling them. Fixes #305
1 parent 86da5e3 commit edfe83a

File tree

6 files changed

+130
-57
lines changed

6 files changed

+130
-57
lines changed

lib/features/home/view/home_page.dart

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ import 'package:tsdm_client/features/local_notice/stream.dart';
1717
import 'package:tsdm_client/features/notification/bloc/notification_state_auto_sync_cubit.dart';
1818
import 'package:tsdm_client/features/notification/models/models.dart';
1919
import 'package:tsdm_client/features/root/bloc/root_location_cubit.dart';
20+
import 'package:tsdm_client/features/root/models/models.dart';
21+
import 'package:tsdm_client/features/root/stream/root_location_stream.dart';
2022
import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart';
2123
import 'package:tsdm_client/features/settings/repositories/settings_repository.dart';
2224
import 'package:tsdm_client/i18n/strings.g.dart';
2325
import 'package:tsdm_client/instance.dart';
2426
import 'package:tsdm_client/routes/screen_paths.dart';
2527
import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart';
26-
import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart';
2728
import 'package:tsdm_client/utils/logger.dart';
2829
import 'package:tsdm_client/utils/platform.dart';
2930
import 'package:tsdm_client/utils/show_dialog.dart';
@@ -34,12 +35,7 @@ const _drawerWidth = 250.0;
3435
/// Page of the homepage of the app.
3536
class HomePage extends StatefulWidget {
3637
/// Constructor.
37-
const HomePage({
38-
required ForumHomeRepository forumHomeRepository,
39-
required this.showNavigationBar,
40-
required this.child,
41-
super.key,
42-
}) : _forumHomeRepository = forumHomeRepository;
38+
const HomePage({required this.showNavigationBar, required this.child, super.key});
4339

4440
/// Control to show the app level navigation bar or not.
4541
///
@@ -49,8 +45,6 @@ class HomePage extends StatefulWidget {
4945
/// Child widget, or call it the body widget.
5046
final Widget child;
5147

52-
final ForumHomeRepository _forumHomeRepository;
53-
5448
@override
5549
State<HomePage> createState() => _HomePageState();
5650
}
@@ -244,39 +238,68 @@ class _HomePageState extends State<HomePage> with LoggerMixin {
244238
);
245239
}
246240

247-
return RepositoryProvider.value(
248-
value: widget._forumHomeRepository,
241+
// The global listener handles app-wide leave page events.
242+
// Every time user intended to leave a certain page, `lastRequestLeavePageTime` is updated and this
243+
// listener process check for leave event, decide the page can be popped or not.
244+
//
245+
// The logic here is to implements double-press before exit app feature. All popping page events are
246+
// intercepted by the `BackButtonListener` below, it only triggers the update of `lastRequestLeavePageTime`,
247+
// which notifies this listener.
248+
//
249+
// Every time the pop is allowed, update page locations in `RootLocationCubit` by adding
250+
// `RootLocationEventLeave` to stream.
251+
return BlocListener<RootLocationCubit, RootLocationState>(
252+
listenWhen: (prev, curr) => prev.lastRequestLeavePageTime != curr.lastRequestLeavePageTime,
253+
listener: (context, state) async {
254+
// Check if fine to pop the current page.
255+
final location = state.locations.lastOrNull;
256+
if (location != ScreenPaths.homepage &&
257+
location != ScreenPaths.topic &&
258+
location != ScreenPaths.settings.path &&
259+
location != null) {
260+
// Popping current page will not close the app, allow to pop.
261+
rootLocationStream.add(RootLocationEventLeave(location));
262+
return context.pop();
263+
}
264+
265+
// Check for double press exit feature.
266+
// From here, if we intend to pop the page, in fact we shall exit the app.
267+
268+
final doublePressExit = getIt.get<SettingsRepository>().currentSettings.doublePressExit;
269+
if (!doublePressExit) {
270+
// Double-press before exit feature is disabled, exit app.
271+
await exitApp();
272+
return;
273+
}
274+
275+
// From here, double-press before exit feature is enabled.
276+
277+
final tr = context.t.home;
278+
final currentTime = DateTime.now();
279+
if (lastPopTime == null ||
280+
currentTime.difference(lastPopTime!).inMilliseconds > exitConfirmDuration.inMilliseconds) {
281+
lastPopTime = currentTime;
282+
ScaffoldMessenger.of(context).hideCurrentSnackBar();
283+
showSnackBar(context: context, message: tr.confirmExit);
284+
// Require the second pop event.
285+
return;
286+
}
287+
288+
// A second pop event is here, exit app.
289+
await exitApp();
290+
// Unreachable
291+
return;
292+
},
249293
child: BackButtonListener(
250294
onBackButtonPressed: () async {
295+
// App wide popping events interceptor, handles all popping events and notify the listener above.
296+
rootLocationStream.add(const RootLocationEventLeavingLast());
251297
if (!context.mounted) {
298+
// Well, leave it here.
252299
await exitApp();
253300
return true;
254301
}
255-
final location = context.read<RootLocationCubit>().current;
256-
if (location != ScreenPaths.homepage &&
257-
location != ScreenPaths.topic &&
258-
location != ScreenPaths.settings.path) {
259-
// Do NOT handle pop events on other pages.
260-
return false;
261-
}
262-
final doublePressExit = getIt.get<SettingsRepository>().currentSettings.doublePressExit;
263-
if (!doublePressExit) {
264-
// Do NOT handle pop events on double press check is disabled.
265-
await exitApp();
266-
return true;
267-
}
268-
final tr = context.t.home;
269-
final currentTime = DateTime.now();
270-
if (lastPopTime == null ||
271-
currentTime.difference(lastPopTime!).inMilliseconds > exitConfirmDuration.inMilliseconds) {
272-
lastPopTime = currentTime;
273-
ScaffoldMessenger.of(context).hideCurrentSnackBar();
274-
showSnackBar(context: context, message: tr.confirmExit);
275-
return true;
276-
}
277-
await exitApp();
278-
// Unreachable
279-
return false;
302+
return true;
280303
},
281304
child: child,
282305
),

lib/features/root/bloc/root_location_cubit.dart

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,60 @@
11
import 'dart:async';
22

33
import 'package:bloc/bloc.dart';
4+
import 'package:dart_mappable/dart_mappable.dart';
45
import 'package:tsdm_client/features/root/models/models.dart';
56
import 'package:tsdm_client/features/root/stream/root_location_stream.dart';
7+
import 'package:tsdm_client/routes/screen_paths.dart';
68
import 'package:tsdm_client/utils/logger.dart';
79

10+
part 'root_location_cubit.mapper.dart';
11+
part 'root_location_state.dart';
12+
813
/// Cubit to store and control current page route.
914
///
1015
/// State is a list (more exactly, stack) of screen paths that pages ever
1116
/// enter currently.
12-
final class RootLocationCubit extends Cubit<List<String>> with LoggerMixin {
17+
///
18+
/// Now this class handles page locations. Every page path saved in state is the page we in or nested in. The global
19+
/// cubit listener is responsible to check pop page logic, ensuring all pop requests are satisfies user settings.
20+
final class RootLocationCubit extends Cubit<RootLocationState> with LoggerMixin {
1321
/// Constructor.
14-
RootLocationCubit() : super(const []) {
22+
RootLocationCubit() : super(const RootLocationState()) {
1523
_sub = rootLocationStream.stream.listen(
1624
(event) => switch (event) {
1725
RootLocationEventEnter(:final path) => () {
26+
// Already enter new page.
1827
debug('enter page $path');
19-
emit(state.toList()..add(path));
28+
emit(state.copyWith(locations: state.locations.toList()..add(path)));
2029
}(),
2130
RootLocationEventLeave(:final path) => () {
31+
// Already leave the current page.
2232
debug('leave page $path');
23-
if (state.lastOrNull != path) {
33+
if (currentPath != path) {
34+
if (path == ScreenPaths.homepage) {
35+
// Special case for shelled route.
36+
//emit(state.toList()..removeWhere((e) => e == path));
37+
return;
38+
}
39+
2440
error(
2541
'failed to leave page non-current path $path, current '
2642
'page is $state',
2743
);
2844
return;
2945
}
30-
emit(state.toList()..removeLast());
46+
emit(state.copyWith(locations: state.locations.toList()..removeLast()));
47+
}(),
48+
RootLocationEventLeavingLast() => () {
49+
// Intend to leave current page.
50+
debug('leave last page');
51+
if (state.locations.isEmpty) {
52+
error('location state already empty');
53+
return;
54+
}
55+
// Till now we do not know it it's find to really leave current page.
56+
// Instead, update request time to notify the global listener which works on this.
57+
emit(state.copyWith(lastRequestLeavePageTime: DateTime.now()));
3158
}(),
3259
},
3360
);
@@ -36,13 +63,13 @@ final class RootLocationCubit extends Cubit<List<String>> with LoggerMixin {
3663
late final StreamSubscription<RootLocationEvent> _sub;
3764

3865
/// Get the current path;
39-
String? get current => state.lastOrNull;
66+
String? get currentPath => state.locations.lastOrNull;
4067

4168
/// Currently in page [path] or not.
42-
bool isIn(String path) => state.lastOrNull == path;
69+
bool isIn(String path) => state.locations.lastOrNull == path;
4370

4471
/// Ever pushed page [path] in route stack or not.
45-
bool ever(String path) => state.contains(path);
72+
bool ever(String path) => state.locations.contains(path);
4673

4774
@override
4875
Future<void> close() async {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
part of 'root_location_cubit.dart';
2+
3+
/// The state of root location cubit.
4+
@MappableClass()
5+
final class RootLocationState with RootLocationStateMappable {
6+
/// Constructor.
7+
const RootLocationState({this.lastRequestLeavePageTime, this.locations = const []});
8+
9+
/// Date time when last request leave page, like an ID of different pop page requests.
10+
///
11+
/// Use this field to identity different pop page requests and trigger the listener which handles double-press
12+
/// exit app feature.
13+
///
14+
/// Literally it is not semantic, but ok.
15+
final DateTime? lastRequestLeavePageTime;
16+
17+
/// Current locations.
18+
///
19+
/// Nested route paths.
20+
final List<String> locations;
21+
}

lib/features/root/models/root_location_event.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ final class RootLocationEventEnter extends RootLocationEvent with RootLocationEv
1818
}
1919

2020
/// Leaved (or say popped) a new page with path [path].
21+
///
22+
/// When this event is triggered, page at [path] is already popped.
2123
@MappableClass()
2224
final class RootLocationEventLeave extends RootLocationEvent with RootLocationEventLeaveMappable {
2325
/// Constructor.
@@ -26,3 +28,15 @@ final class RootLocationEventLeave extends RootLocationEvent with RootLocationEv
2628
/// Path of page entered.
2729
final String path;
2830
}
31+
32+
/// About to leave the last page in location.
33+
///
34+
/// Use this event to let the cubit knows: The last page is going to pop.
35+
///
36+
/// When this event is triggered, it does not mean the last page is already popped, only a request to trigger leave
37+
/// page checking logic for the cubit.
38+
@MappableClass()
39+
final class RootLocationEventLeavingLast extends RootLocationEvent with RootLocationEventLeavingLastMappable {
40+
/// Constructor.
41+
const RootLocationEventLeavingLast();
42+
}

lib/features/root/view/root_page.dart

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@ class _RootPageState extends State<RootPage> with LoggerMixin {
2626
rootLocationStream.add(RootLocationEventEnter(widget.path));
2727
}
2828

29-
@override
30-
void dispose() {
31-
rootLocationStream.add(RootLocationEventLeave(widget.path));
32-
super.dispose();
33-
}
34-
3529
@override
3630
Widget build(BuildContext context) {
3731
return widget.child;

lib/routes/app_routes.dart

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'package:flutter/material.dart';
2-
import 'package:flutter_bloc/flutter_bloc.dart';
32
import 'package:go_router/go_router.dart';
43
import 'package:tsdm_client/extensions/string.dart';
54
import 'package:tsdm_client/features/authentication/view/login_page.dart';
@@ -49,7 +48,6 @@ import 'package:tsdm_client/features/update/view/local_changelog_page.dart';
4948
import 'package:tsdm_client/features/update/view/update_page.dart';
5049
import 'package:tsdm_client/routes/screen_paths.dart';
5150
import 'package:tsdm_client/shared/models/models.dart';
52-
import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart';
5351

5452
/// App router instance wrapped with global singleton widgets.
5553
final router = GoRouter(
@@ -62,11 +60,7 @@ final _appRoutes = [
6260
StatefulShellRoute.indexedStack(
6361
builder: (context, router, navigator) {
6462
final hideNavigationBarPages = [ScreenPaths.settingsThreadAppearance.fullPath];
65-
return HomePage(
66-
forumHomeRepository: RepositoryProvider.of<ForumHomeRepository>(context),
67-
showNavigationBar: !hideNavigationBarPages.contains(router.fullPath),
68-
child: navigator,
69-
);
63+
return HomePage(showNavigationBar: !hideNavigationBarPages.contains(router.fullPath), child: navigator);
7064
},
7165
branches: [
7266
StatefulShellBranch(

0 commit comments

Comments
 (0)