Rewrite double tap to exit code

This commit is contained in:
Ming Ming 2024-10-28 22:03:17 +08:00
parent 59fff6d679
commit f789d0473b
7 changed files with 405 additions and 165 deletions

View file

@ -0,0 +1,63 @@
part of 'double_tap_exit_container.dart';
@npLog
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_Bloc({
required this.prefController,
}) : super(_State.init(
isDoubleTapExit: prefController.isDoubleTapExitValue,
)) {
on<_SetDoubleTapExit>(_onSetDoubleTapExit);
on<_SetCanPop>(_onSetCanPop);
on<_OnPopInvoked>(_onOnPopInvoked);
_subscriptions.add(prefController.isDoubleTapExitChange.listen((ev) {
add(_SetDoubleTapExit(ev));
}));
}
@override
Future<void> close() {
for (final s in _subscriptions) {
s.cancel();
}
_timer?.cancel();
return super.close();
}
@override
String get tag => _log.fullName;
void _onSetDoubleTapExit(_SetDoubleTapExit ev, _Emitter emit) {
_log.info(ev);
emit(state.copyWith(isDoubleTapExit: ev.value));
}
void _onSetCanPop(_SetCanPop ev, _Emitter emit) {
_log.info(ev);
emit(state.copyWith(canPop: ev.value));
}
void _onOnPopInvoked(_OnPopInvoked ev, _Emitter emit) {
_log.info(ev);
if (state.isDoubleTapExit && !state.canPop) {
emit(state.copyWith(canPop: true));
_timer?.cancel();
_timer = Timer(
const Duration(seconds: 5),
() {
add(const _SetCanPop(false));
},
);
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().doubleTapExitNotification),
duration: k.snackBarDurationShort,
));
}
}
final PrefController prefController;
final _subscriptions = <StreamSubscription>[];
Timer? _timer;
}

View file

@ -0,0 +1,72 @@
import 'dart:async';
import 'package:copy_with/copy_with.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'bloc.dart';
part 'double_tap_exit_container.g.dart';
part 'state_event.dart';
class DoubleTapExitContainer extends StatelessWidget {
const DoubleTapExitContainer({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => _Bloc(
prefController: context.read(),
),
child: _WrappedDoubleTapExitContainer(child: child),
);
}
final Widget child;
}
class _WrappedDoubleTapExitContainer extends StatelessWidget {
const _WrappedDoubleTapExitContainer({
required this.child,
});
@override
Widget build(BuildContext context) {
return _BlocBuilder(
buildWhen: (previous, current) =>
previous.isDoubleTapExit != current.isDoubleTapExit ||
previous.canPop != current.canPop,
builder: (context, state) => PopScope(
canPop: !state.isDoubleTapExit || state.canPop,
onPopInvoked: (didPop) {
context.addEvent(_OnPopInvoked(didPop));
},
child: child,
),
);
}
final Widget child;
}
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
// typedef _BlocListener = BlocListener<_Bloc, _State>;
// typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
// typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
typedef _Emitter = Emitter<_State>;
extension on BuildContext {
_Bloc get bloc => read<_Bloc>();
// _State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event);
}

View file

@ -0,0 +1,79 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'double_tap_exit_container.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $_StateCopyWithWorker {
_State call({bool? isDoubleTapExit, bool? canPop});
}
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
_$_StateCopyWithWorkerImpl(this.that);
@override
_State call({dynamic isDoubleTapExit, dynamic canPop}) {
return _State(
isDoubleTapExit: isDoubleTapExit as bool? ?? that.isDoubleTapExit,
canPop: canPop as bool? ?? that.canPop);
}
final _State that;
}
extension $_StateCopyWith on _State {
$_StateCopyWithWorker get copyWith => _$copyWith;
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_BlocNpLog on _Bloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger(
"widget.double_tap_exit_container.double_tap_exit_container._Bloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$_StateToString on _State {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_State {isDoubleTapExit: $isDoubleTapExit, canPop: $canPop}";
}
}
extension _$_SetDoubleTapExitToString on _SetDoubleTapExit {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetDoubleTapExit {value: $value}";
}
}
extension _$_SetCanPopToString on _SetCanPop {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetCanPop {value: $value}";
}
}
extension _$_OnPopInvokedToString on _OnPopInvoked {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_OnPopInvoked {didPop: $didPop}";
}
}

View file

@ -0,0 +1,56 @@
part of 'double_tap_exit_container.dart';
@genCopyWith
@toString
class _State {
const _State({
required this.isDoubleTapExit,
required this.canPop,
});
factory _State.init({
required bool isDoubleTapExit,
}) =>
_State(
isDoubleTapExit: isDoubleTapExit,
canPop: false,
);
@override
String toString() => _$toString();
final bool isDoubleTapExit;
final bool canPop;
}
abstract class _Event {}
@toString
class _SetDoubleTapExit implements _Event {
const _SetDoubleTapExit(this.value);
@override
String toString() => _$toString();
final bool value;
}
@toString
class _SetCanPop implements _Event {
const _SetCanPop(this.value);
@override
String toString() => _$toString();
final bool value;
}
@toString
class _OnPopInvoked implements _Event {
const _OnPopInvoked(this.didPop);
@override
String toString() => _$toString();
final bool didPop;
}

View file

@ -1,35 +0,0 @@
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
class DoubleTapExitHandler {
factory DoubleTapExitHandler() => _inst;
DoubleTapExitHandler._();
/// Return if this back button event should actually exit the app
bool call() {
if (!Pref().isDoubleTapExitOr()) {
return true;
}
final now = clock.now().toUtc();
_lastBackButtonAt ??= now.subtract(const Duration(days: 1));
if (now.difference(_lastBackButtonAt!) < const Duration(seconds: 5)) {
return true;
} else {
_lastBackButtonAt = now;
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().doubleTapExitNotification),
duration: k.snackBarDurationShort,
));
return false;
}
}
static final _inst = DoubleTapExitHandler._();
DateTime? _lastBackButtonAt;
}

View file

@ -33,9 +33,9 @@ import 'package:nc_photos/widget/album_importer.dart';
import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/archive_browser.dart';
import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_browser.dart';
import 'package:nc_photos/widget/collection_grid_item.dart'; import 'package:nc_photos/widget/collection_grid_item.dart';
import 'package:nc_photos/widget/double_tap_exit_container/double_tap_exit_container.dart';
import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/enhanced_photo_browser.dart';
import 'package:nc_photos/widget/fade_out_list.dart'; import 'package:nc_photos/widget/fade_out_list.dart';
import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart';
import 'package:nc_photos/widget/home_app_bar.dart'; import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/map_browser.dart'; import 'package:nc_photos/widget/map_browser.dart';
import 'package:nc_photos/widget/navigation_bar_blur_filter.dart'; import 'package:nc_photos/widget/navigation_bar_blur_filter.dart';
@ -46,7 +46,6 @@ import 'package:nc_photos/widget/selection_app_bar.dart';
import 'package:nc_photos/widget/sharing_browser.dart'; import 'package:nc_photos/widget/sharing_browser.dart';
import 'package:nc_photos/widget/trashbin_browser.dart'; import 'package:nc_photos/widget/trashbin_browser.dart';
import 'package:np_codegen/np_codegen.dart'; import 'package:np_codegen/np_codegen.dart';
import 'package:np_platform_util/np_platform_util.dart';
import 'package:np_ui/np_ui.dart'; import 'package:np_ui/np_ui.dart';
import 'package:to_string/to_string.dart'; import 'package:to_string/to_string.dart';
@ -96,7 +95,7 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = MultiBlocListener( return MultiBlocListener(
listeners: [ listeners: [
_BlocListener( _BlocListener(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
@ -127,12 +126,37 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
}, },
), ),
], ],
child: Stack( child: _BlocSelector(
selector: (state) => state.selectedItems.isEmpty,
builder: (context, isSelectedEmpty) => isSelectedEmpty
? const DoubleTapExitContainer(
child: _BodyView(),
)
: PopScope(
canPop: false,
onPopInvoked: (_) {
context.addEvent(const _SetSelectedItems(items: {}));
},
child: const _BodyView(),
),
),
);
}
late final _Bloc _bloc = context.read();
}
class _BodyView extends StatelessWidget {
const _BodyView();
@override
Widget build(BuildContext context) {
return Stack(
children: [ children: [
RefreshIndicator( RefreshIndicator(
onRefresh: () async { onRefresh: () async {
_bloc.add(const _ReloadCollections()); context.addEvent(const _ReloadCollections());
await _bloc.stream.first; await context.bloc.stream.first;
}, },
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
@ -163,7 +187,7 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
selector: (state) => selector: (state) =>
state.itemCounts[item.collection.id], state.itemCounts[item.collection.id],
builder: (context, itemCount) => _ItemView( builder: (context, itemCount) => _ItemView(
account: _bloc.account, account: context.bloc.account,
item: item, item: item,
collectionItemCountOverride: itemCount, collectionItemCountOverride: itemCount,
), ),
@ -173,13 +197,13 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
const StaggeredTile.count(1, 1), const StaggeredTile.count(1, 1),
selectedItems: state.selectedItems, selectedItems: state.selectedItems,
onSelectionChange: (_, selected) { onSelectionChange: (_, selected) {
_bloc.add(_SetSelectedItems(items: selected.cast())); context
.addEvent(_SetSelectedItems(items: selected.cast()));
}, },
onItemTap: (context, _, item) { onItemTap: (context, _, item) {
Navigator.of(context).pushNamed( Navigator.of(context).pushNamed(
CollectionBrowser.routeName, CollectionBrowser.routeName,
arguments: arguments: CollectionBrowserArguments(item.collection),
CollectionBrowserArguments(item.collection),
); );
}, },
), ),
@ -200,28 +224,8 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
), ),
), ),
], ],
),
); );
if (getRawPlatform() == NpPlatform.android) {
return WillPopScope(
onWillPop: () => _onBackButtonPressed(context),
child: content,
);
} else {
return content;
} }
}
Future<bool> _onBackButtonPressed(BuildContext context) async {
if (context.state.selectedItems.isEmpty) {
return DoubleTapExitHandler()();
} else {
context.addEvent(const _SetSelectedItems(items: {}));
return false;
}
}
late final _Bloc _bloc = context.read();
} }
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;

View file

@ -45,9 +45,9 @@ import 'package:nc_photos/theme/dimension.dart';
import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_browser.dart';
import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/collection_picker.dart';
import 'package:nc_photos/widget/double_tap_exit_container/double_tap_exit_container.dart';
import 'package:nc_photos/widget/file_sharer_dialog.dart'; import 'package:nc_photos/widget/file_sharer_dialog.dart';
import 'package:nc_photos/widget/finger_listener.dart'; import 'package:nc_photos/widget/finger_listener.dart';
import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart';
import 'package:nc_photos/widget/home_app_bar.dart'; import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/navigation_bar_blur_filter.dart'; import 'package:nc_photos/widget/navigation_bar_blur_filter.dart';
import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/network_thumbnail.dart';
@ -64,7 +64,6 @@ import 'package:np_common/object_util.dart';
import 'package:np_common/or_null.dart'; import 'package:np_common/or_null.dart';
import 'package:np_datetime/np_datetime.dart'; import 'package:np_datetime/np_datetime.dart';
import 'package:np_db/np_db.dart'; import 'package:np_db/np_db.dart';
import 'package:np_platform_util/np_platform_util.dart';
import 'package:np_ui/np_ui.dart'; import 'package:np_ui/np_ui.dart';
import 'package:to_string/to_string.dart'; import 'package:to_string/to_string.dart';
import 'package:visibility_detector/visibility_detector.dart'; import 'package:visibility_detector/visibility_detector.dart';
@ -116,12 +115,12 @@ class _WrappedHomePhotosState extends State<_WrappedHomePhotos> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bloc.add(const _LoadItems()); context.addEvent(const _LoadItems());
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = VisibilityDetector( return VisibilityDetector(
key: _key, key: _key,
onVisibilityChanged: (info) { onVisibilityChanged: (info) {
final isVisible = info.visibleFraction >= 0.2; final isVisible = info.visibleFraction >= 0.2;
@ -177,9 +176,35 @@ class _WrappedHomePhotosState extends State<_WrappedHomePhotos> {
}, },
), ),
], ],
child: _BlocSelector<bool>( child: _BlocSelector(
selector: (state) => selector: (state) => state.selectedItems.isEmpty,
state.files.isEmpty && state.syncProgress != null, builder: (context, isSelectedEmpty) => isSelectedEmpty
? const DoubleTapExitContainer(
child: _BodyView(),
)
: PopScope(
canPop: false,
onPopInvoked: (_) {
context.addEvent(const _SetSelectedItems(items: {}));
},
child: const _BodyView(),
),
),
),
);
}
final _key = GlobalKey();
bool? _isVisible;
}
class _BodyView extends StatelessWidget {
const _BodyView();
@override
Widget build(BuildContext context) {
return _BlocSelector<bool>(
selector: (state) => state.files.isEmpty && state.syncProgress != null,
builder: (context, isInitialSyncing) { builder: (context, isInitialSyncing) {
if (isInitialSyncing) { if (isInitialSyncing) {
return const _InitialSyncBody(); return const _InitialSyncBody();
@ -190,32 +215,8 @@ class _WrappedHomePhotosState extends State<_WrappedHomePhotos> {
); );
} }
}, },
),
),
); );
if (getRawPlatform() == NpPlatform.android) {
return WillPopScope(
onWillPop: () => _onBackButtonPressed(context),
child: content,
);
} else {
return content;
} }
}
Future<bool> _onBackButtonPressed(BuildContext context) async {
if (context.state.selectedItems.isEmpty) {
return DoubleTapExitHandler()();
} else {
context.addEvent(const _SetSelectedItems(items: {}));
return false;
}
}
late final _bloc = context.bloc;
final _key = GlobalKey();
bool? _isVisible;
} }
class _InitialSyncBody extends StatelessWidget { class _InitialSyncBody extends StatelessWidget {
@ -521,7 +522,7 @@ typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
extension on BuildContext { extension on BuildContext {
_Bloc get bloc => read<_Bloc>(); _Bloc get bloc => read<_Bloc>();
_State get state => bloc.state; // _State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event); void addEvent(_Event event) => bloc.add(event);
} }