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,103 +126,108 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections>
}, },
), ),
], ],
child: Stack( child: _BlocSelector(
children: [ selector: (state) => state.selectedItems.isEmpty,
RefreshIndicator( builder: (context, isSelectedEmpty) => isSelectedEmpty
onRefresh: () async { ? const DoubleTapExitContainer(
_bloc.add(const _ReloadCollections()); child: _BodyView(),
await _bloc.stream.first; )
}, : PopScope(
child: CustomScrollView( canPop: false,
slivers: [ onPopInvoked: (_) {
_BlocBuilder( context.addEvent(const _SetSelectedItems(items: {}));
buildWhen: (previous, current) => },
previous.selectedItems.isEmpty != child: const _BodyView(),
current.selectedItems.isEmpty, ),
builder: (context, state) => state.selectedItems.isEmpty
? const _AppBar()
: const _SelectionAppBar(),
),
const SliverToBoxAdapter(
child: SizedBox(height: 8),
),
_BlocBuilder(
buildWhen: (previous, current) =>
previous.transformedItems != current.transformedItems ||
previous.selectedItems != current.selectedItems,
builder: (context, state) => SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SelectableItemList(
maxCrossAxisExtent: 256,
childBorderRadius: BorderRadius.zero,
indicatorAlignment: const Alignment(-.92, -.92),
items: state.transformedItems,
itemBuilder: (_, __, item) {
return _BlocSelector<int?>(
selector: (state) =>
state.itemCounts[item.collection.id],
builder: (context, itemCount) => _ItemView(
account: _bloc.account,
item: item,
collectionItemCountOverride: itemCount,
),
);
},
staggeredTileBuilder: (_, __) =>
const StaggeredTile.count(1, 1),
selectedItems: state.selectedItems,
onSelectionChange: (_, selected) {
_bloc.add(_SetSelectedItems(items: selected.cast()));
},
onItemTap: (context, _, item) {
Navigator.of(context).pushNamed(
CollectionBrowser.routeName,
arguments:
CollectionBrowserArguments(item.collection),
);
},
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: AppDimension.of(context).homeBottomAppBarHeight,
),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: NavigationBarBlurFilter(
height: AppDimension.of(context).homeBottomAppBarHeight,
),
),
],
), ),
); );
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(); late final _Bloc _bloc = context.read();
} }
class _BodyView extends StatelessWidget {
const _BodyView();
@override
Widget build(BuildContext context) {
return Stack(
children: [
RefreshIndicator(
onRefresh: () async {
context.addEvent(const _ReloadCollections());
await context.bloc.stream.first;
},
child: CustomScrollView(
slivers: [
_BlocBuilder(
buildWhen: (previous, current) =>
previous.selectedItems.isEmpty !=
current.selectedItems.isEmpty,
builder: (context, state) => state.selectedItems.isEmpty
? const _AppBar()
: const _SelectionAppBar(),
),
const SliverToBoxAdapter(
child: SizedBox(height: 8),
),
_BlocBuilder(
buildWhen: (previous, current) =>
previous.transformedItems != current.transformedItems ||
previous.selectedItems != current.selectedItems,
builder: (context, state) => SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SelectableItemList(
maxCrossAxisExtent: 256,
childBorderRadius: BorderRadius.zero,
indicatorAlignment: const Alignment(-.92, -.92),
items: state.transformedItems,
itemBuilder: (_, __, item) {
return _BlocSelector<int?>(
selector: (state) =>
state.itemCounts[item.collection.id],
builder: (context, itemCount) => _ItemView(
account: context.bloc.account,
item: item,
collectionItemCountOverride: itemCount,
),
);
},
staggeredTileBuilder: (_, __) =>
const StaggeredTile.count(1, 1),
selectedItems: state.selectedItems,
onSelectionChange: (_, selected) {
context
.addEvent(_SetSelectedItems(items: selected.cast()));
},
onItemTap: (context, _, item) {
Navigator.of(context).pushNamed(
CollectionBrowser.routeName,
arguments: CollectionBrowserArguments(item.collection),
);
},
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: AppDimension.of(context).homeBottomAppBarHeight,
),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: NavigationBarBlurFilter(
height: AppDimension.of(context).homeBottomAppBarHeight,
),
),
],
);
}
}
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocListener = BlocListener<_Bloc, _State>;
// typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>; // typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;

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,47 +176,49 @@ 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
builder: (context, isInitialSyncing) { ? const DoubleTapExitContainer(
if (isInitialSyncing) { child: _BodyView(),
return const _InitialSyncBody(); )
} else { : PopScope(
return Shimmer( canPop: false,
linearGradient: Theme.of(context).photoGridShimmerGradient, onPopInvoked: (_) {
child: const _Body(), context.addEvent(const _SetSelectedItems(items: {}));
); },
} child: const _BodyView(),
}, ),
), ),
), ),
); );
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(); final _key = GlobalKey();
bool? _isVisible; 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) {
if (isInitialSyncing) {
return const _InitialSyncBody();
} else {
return Shimmer(
linearGradient: Theme.of(context).photoGridShimmerGradient,
child: const _Body(),
);
}
},
);
}
}
class _InitialSyncBody extends StatelessWidget { class _InitialSyncBody extends StatelessWidget {
const _InitialSyncBody(); const _InitialSyncBody();
@ -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);
} }