improve bottom sheet handling

This commit is contained in:
Fabian Freund
2025-10-03 12:15:53 +02:00
parent 59c567d495
commit 805d39e300
10 changed files with 352 additions and 324 deletions
@@ -85,7 +85,7 @@ class SiteSearch extends HookConsumerWidget {
.read(tabSessionProvider(tabId: null).notifier) .read(tabSessionProvider(tabId: null).notifier)
.loadUrl(url: newUrl); .loadUrl(url: newUrl);
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
} else if (bang != null) { } else if (bang != null) {
final isPrivate = final isPrivate =
ref.read(selectedTabStateProvider)?.isPrivate ?? ref.read(selectedTabStateProvider)?.isPrivate ??
@@ -112,7 +112,7 @@ class SiteSearch extends HookConsumerWidget {
.loadUrl(url: searchUri); .loadUrl(url: searchUri);
} }
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
} }
} }
} }
@@ -30,12 +30,19 @@ class BottomSheetController extends _$BottomSheetController {
return null; return null;
} }
///We depend on a listener that updates/syncs UI to open the sheet
// ignore: use_setters_to_change_properties api decision // ignore: use_setters_to_change_properties api decision
void show(Sheet sheet) { void show(Sheet sheet) {
state = sheet; state = sheet;
} }
void dismiss() { ///We depend on a listener that updates/syncs UI to close the sheet
void requestDismiss() {
state = null;
}
///This is called by UI when the sheet gets closed
void closed() {
state = null; state = null;
} }
} }
@@ -42,7 +42,7 @@ final class BottomSheetControllerProvider
} }
String _$bottomSheetControllerHash() => String _$bottomSheetControllerHash() =>
r'94b9467731de45d4edd24028d76279e17fc26d02'; r'21c353d433c8a964d63915cc4ebf5767e16210b1';
abstract class _$BottomSheetController extends $Notifier<Sheet?> { abstract class _$BottomSheetController extends $Notifier<Sheet?> {
Sheet? build(); Sheet? build();
@@ -190,7 +190,7 @@ class TabTreeDialog extends HookConsumerWidget {
context.pop(); context.pop();
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.dismiss(); .requestDismiss();
}, },
sourceSearchQuery: null, sourceSearchQuery: null,
), ),
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -59,9 +60,6 @@ class BrowserScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final eventService = ref.watch(eventServiceProvider); final eventService = ref.watch(eventServiceProvider);
final sheetDisplayed = ref.watch(
bottomSheetControllerProvider.select((value) => value != null),
);
final overlayBuilder = ref.watch(overlayControllerProvider); final overlayBuilder = ref.watch(overlayControllerProvider);
final tabInFullScreen = ref.watch( final tabInFullScreen = ref.watch(
@@ -107,342 +105,343 @@ class BrowserScreen extends HookConsumerWidget {
final sheetController = useRef<PersistentBottomSheetController?>(null); final sheetController = useRef<PersistentBottomSheetController?>(null);
return TickerMode( return PopScope(
//Lets not pause riverpod subscriptions for nnow //We need this for BackButtonListener to work downstream
enabled: true, //No direct pop result will be handled here
child: PopScope( canPop: false,
//We need this for BackButtonListener to work downstream child: Theme(
//No direct pop result will be handled here data: themeData,
canPop: false, child: Scaffold(
child: Theme( extendBodyBehindAppBar: tabInFullScreen,
data: themeData, bottomSheetScrimBuilder: (_, _) {
child: Scaffold( //This causes issues with a non dismissable barrier pushed, we ahve our own barrier and this does seem to have issues when dismissing, so disable it completely
extendBodyBehindAppBar: tabInFullScreen, return null;
bottomNavigationBar: HookConsumer( },
builder: (context, ref, child) { bottomNavigationBar: HookConsumer(
final tabId = ref.watch(selectedTabProvider); builder: (context, ref, child) {
final displayedSheet = ref.watch(bottomSheetControllerProvider); final tabId = ref.watch(selectedTabProvider);
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final tabInFullScreen = ref.watch( final tabInFullScreen = ref.watch(
selectedTabStateProvider.select( selectedTabStateProvider.select(
(value) => value?.isFullScreen ?? false, (value) => value?.isFullScreen ?? false,
), ),
);
final autoHideTabBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.autoHideTabBar,
),
);
if (!autoHideTabBar) {
return Visibility(
visible: !tabInFullScreen,
child: BrowserBottomAppBar(displayedSheet: displayedSheet),
); );
}
final autoHideTabBar = ref.watch( final appBarVisible = useValueListenable(showAppBar);
generalSettingsWithDefaultsProvider.select( final diffAcc = useRef(0.0);
(value) => value.autoHideTabBar,
),
);
if (!autoHideTabBar) { void resetHiddenState() {
return Visibility( showAppBar.value = true;
visible: !tabInFullScreen, diffAcc.value = 0.0;
child: BrowserBottomAppBar(displayedSheet: displayedSheet), }
);
}
final appBarVisible = useValueListenable(showAppBar); useEffect(() {
final diffAcc = useRef(0.0); WidgetsBinding.instance.addPostFrameCallback((_) {
resetHiddenState();
void resetHiddenState() {
showAppBar.value = true;
diffAcc.value = 0.0;
}
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
resetHiddenState();
});
return null;
}, [tabId]);
useOnAppLifecycleStateChange((previous, current) {
if (current == AppLifecycleState.resumed) {
resetHiddenState();
}
}); });
ref.listen( return null;
tabStateProvider(tabId).select((value) => value?.isLoading), }, [tabId]);
(previous, next) {
if (next == true) { useOnAppLifecycleStateChange((previous, current) {
if (current == AppLifecycleState.resumed) {
resetHiddenState();
}
});
ref.listen(
tabStateProvider(tabId).select((value) => value?.isLoading),
(previous, next) {
if (next == true) {
resetHiddenState();
}
},
);
ref.listen(
tabStateProvider(tabId).select((value) => value?.historyState),
(previous, next) {
if (next != null && previous != null) {
if (previous != next) {
resetHiddenState(); resetHiddenState();
} }
}, }
); },
);
ref.listen( ref.listen(
tabStateProvider( tabScrollYProvider(tabId, const Duration(milliseconds: 50)),
tabId, (previous, next) {
).select((value) => value?.historyState), if (previous?.value != null && next.value != null) {
(previous, next) { final diff = previous!.value! - next.value!;
if (next != null && previous != null) { if (diff < 0) {
if (previous != next) { if (diffAcc.value > 0) {
diffAcc.value = 0.0;
}
diffAcc.value += diff;
if (diffAcc.value.abs() > kToolbarHeight * 1.5) {
showAppBar.value = false;
}
} else if (diff > 0) {
if (diffAcc.value < 0) {
diffAcc.value = 0.0;
}
diffAcc.value += diff;
if (diffAcc.value.abs() > (kToolbarHeight / 2)) {
resetHiddenState(); resetHiddenState();
} }
} }
},
);
ref.listen(
tabScrollYProvider(tabId, const Duration(milliseconds: 50)),
(previous, next) {
if (previous?.value != null && next.value != null) {
final diff = previous!.value! - next.value!;
if (diff < 0) {
if (diffAcc.value > 0) {
diffAcc.value = 0.0;
}
diffAcc.value += diff;
if (diffAcc.value.abs() > kToolbarHeight * 1.5) {
showAppBar.value = false;
}
} else if (diff > 0) {
if (diffAcc.value < 0) {
diffAcc.value = 0.0;
}
diffAcc.value += diff;
if (diffAcc.value.abs() > (kToolbarHeight / 2)) {
resetHiddenState();
}
}
}
},
);
return Visibility(
visible: !tabInFullScreen && appBarVisible,
child: BrowserBottomAppBar(displayedSheet: displayedSheet),
);
},
),
body: Consumer(
child: DragTarget<TabDragData>(
onMove: (details) {
ref
.read(willAcceptDropProvider.notifier)
.setData(DeleteDropData(details.data.tabId));
},
onLeave: (data) {
ref.read(willAcceptDropProvider.notifier).clear();
},
onAcceptWithDetails: (details) async {
ref.read(willAcceptDropProvider.notifier).clear();
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(details.data.tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
} }
}, },
builder: (context, _, _) { );
return OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return overlayBuilder!.call(context);
},
child: Listener(
onPointerDown: sheetDisplayed
? (_) {
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
}
: null,
child: BackButtonListener(
onBackButtonPressed: () async {
final tabState = ref.read(selectedTabStateProvider);
final tabCount = ref.read( return Visibility(
tabListProvider.select((tabs) => tabs.value.length), visible: !tabInFullScreen && appBarVisible,
); child: BrowserBottomAppBar(displayedSheet: displayedSheet),
);
},
),
body: Consumer(
child: DragTarget<TabDragData>(
onMove: (details) {
ref
.read(willAcceptDropProvider.notifier)
.setData(DeleteDropData(details.data.tabId));
},
onLeave: (data) {
ref.read(willAcceptDropProvider.notifier).clear();
},
onAcceptWithDetails: (details) async {
ref.read(willAcceptDropProvider.notifier).clear();
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(details.data.tabId);
//Don't do anything if a child route is active if (context.mounted) {
if (GoRouterState.of(context).topRoute?.name != ui_helper.showTabUndoClose(
BrowserRoute.name) { context,
return false; ref.read(tabRepositoryProvider.notifier).undoClose,
} );
}
if (sheetDisplayed) { },
builder: (context, _, _) {
return OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return overlayBuilder!.call(context);
},
child: Listener(
onPointerDown: sheetController.value != null
? (_) {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.dismiss(); .requestDismiss();
return true;
} }
: null,
child: BackButtonListener(
onBackButtonPressed: () async {
final tabState = ref.read(selectedTabStateProvider);
if (overlayBuilder != null) { final tabCount = ref.read(
ref tabListProvider.select((tabs) => tabs.value.length),
.read(overlayControllerProvider.notifier) );
.dismiss();
return true;
}
if (tabState?.isFullScreen == true) { //Don't do anything if a child route is active
await ref if (GoRouterState.of(context).topRoute?.name !=
.read(selectedTabSessionProvider) BrowserRoute.name) {
.exitFullscreen(); return false;
return true; }
}
//Make sure app bar is visible if (ref.read(bottomSheetControllerProvider) != null) {
showAppBar.value = true;
if (tabState?.isLoading == true) {
lastBackButtonPress.value = null;
final controller = ref.read(
selectedTabSessionProvider,
);
await controller.stopLoading();
return true;
} else if (tabState?.readerableState.active == true) {
lastBackButtonPress.value = null;
await ref
.read(
readerableScreenControllerProvider.notifier,
)
.toggleReaderView(false);
return true;
} else if (tabState?.historyState.canGoBack == true) {
lastBackButtonPress.value = null;
final controller = ref.read(
selectedTabSessionProvider,
);
await controller.goBack();
return true;
}
//Go router has routes to go back to
if (context.canPop()) {
return true;
}
if (ref
.read(tabRepositoryProvider.notifier)
.hasLaunchedFromIntent(tabState?.id)) {
//Mark back as unhandled and navigator will pop
await SystemNavigator.pop();
return false;
}
if (lastBackButtonPress.value != null &&
DateTime.now().difference(
lastBackButtonPress.value!,
) <
_backButtonPressTimeout) {
lastBackButtonPress.value = null;
if (tabState != null && tabCount > 1) {
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(tabState.id);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
);
}
return true;
} else {
//Mark back as unhandled and navigator will pop
await SystemNavigator.pop();
return false;
}
} else {
lastBackButtonPress.value = DateTime.now();
ui_helper.showTabBackButtonMessage(
context,
tabCount,
_backButtonPressTimeout,
);
return true;
}
},
child: _BrowserView(
sheetDisplayed: sheetDisplayed,
isFullscreen: tabInFullScreen,
),
),
),
);
},
),
builder: (context, ref, child) {
ref.listen(bottomSheetControllerProvider, (previous, next) {
if (sheetController.value != null) {
sheetController.value!.close();
sheetController.value = null;
}
if (next != null) {
final relativeSafeArea = MediaQuery.of(
context,
).relativeSafeArea();
sheetController
.value = Scaffold.of(context).showBottomSheet((
context,
) {
bool dismissOnThreshold(
DraggableScrollableNotification notification,
) {
if (notification.extent <= 0.1) {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.requestDismiss();
return true;
}
if (overlayBuilder != null) {
ref
.read(overlayControllerProvider.notifier)
.dismiss(); .dismiss();
return true; return true;
} }
return false; if (tabState?.isFullScreen == true) {
} await ref
.read(selectedTabSessionProvider)
.exitFullscreen();
return true;
}
final sheet = switch (next) { //Make sure app bar is visible
ViewTabsSheet() => showAppBar.value = true;
NotificationListener<DraggableScrollableNotification>(
key: ValueKey(next),
onNotification: dismissOnThreshold,
child: _ViewTabsSheet(
maxChildSize: relativeSafeArea,
),
),
final EditUrlSheet parameter =>
NotificationListener<DraggableScrollableNotification>(
key: ValueKey(parameter),
onNotification: dismissOnThreshold,
child: _ViewUrlSheet(
initialTabState: parameter.tabState,
maxChildSize: relativeSafeArea,
),
),
};
return sheet; if (tabState?.isLoading == true) {
}); lastBackButtonPress.value = null;
}
});
return child!; final controller = ref.read(
selectedTabSessionProvider,
);
await controller.stopLoading();
return true;
} else if (tabState?.readerableState.active == true) {
lastBackButtonPress.value = null;
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(false);
return true;
} else if (tabState?.historyState.canGoBack == true) {
lastBackButtonPress.value = null;
final controller = ref.read(
selectedTabSessionProvider,
);
await controller.goBack();
return true;
}
//Go router has routes to go back to
if (context.canPop()) {
return true;
}
if (ref
.read(tabRepositoryProvider.notifier)
.hasLaunchedFromIntent(tabState?.id)) {
//Mark back as unhandled and navigator will pop
await SystemNavigator.pop();
return false;
}
if (lastBackButtonPress.value != null &&
DateTime.now().difference(
lastBackButtonPress.value!,
) <
_backButtonPressTimeout) {
lastBackButtonPress.value = null;
if (tabState != null && tabCount > 1) {
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(tabState.id);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
);
}
return true;
} else {
//Mark back as unhandled and navigator will pop
await SystemNavigator.pop();
return false;
}
} else {
lastBackButtonPress.value = DateTime.now();
ui_helper.showTabBackButtonMessage(
context,
tabCount,
_backButtonPressTimeout,
);
return true;
}
},
child: _BrowserView(
sheetDisplayed: sheetController.value != null,
isFullscreen: tabInFullScreen,
),
),
),
);
}, },
), ),
floatingActionButton: ReaderAppearanceButton(), builder: (context, ref, child) {
ref.listen(bottomSheetControllerProvider, (previous, next) {
if (sheetController.value != null) {
sheetController.value!.close();
sheetController.value = null;
}
if (next != null) {
final relativeSafeArea = MediaQuery.of(
context,
).relativeSafeArea();
final controller = Scaffold.of(context).showBottomSheet((
context,
) {
bool dismissOnThreshold(
DraggableScrollableNotification notification,
) {
if (notification.extent <= 0.1) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
return true;
}
return false;
}
final sheet = switch (next) {
ViewTabsSheet() =>
NotificationListener<DraggableScrollableNotification>(
key: ValueKey(next),
onNotification: dismissOnThreshold,
child: _ViewTabsSheet(maxChildSize: relativeSafeArea),
),
final EditUrlSheet parameter =>
NotificationListener<DraggableScrollableNotification>(
key: ValueKey(parameter),
onNotification: dismissOnThreshold,
child: _ViewUrlSheet(
initialTabState: parameter.tabState,
maxChildSize: relativeSafeArea,
),
),
};
return sheet;
});
unawaited(
controller.closed.whenComplete(() {
ref.read(bottomSheetControllerProvider.notifier).closed();
}),
);
sheetController.value = controller;
}
});
return child!;
},
), ),
floatingActionButton: ReaderAppearanceButton(),
), ),
), ),
); );
@@ -563,7 +562,7 @@ class _ViewUrlSheet extends HookConsumerWidget {
sheetScrollController: scrollController, sheetScrollController: scrollController,
draggableScrollableController: draggableScrollableController, draggableScrollableController: draggableScrollableController,
onClose: () { onClose: () {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
}, },
initialHeight: initialHeight, initialHeight: initialHeight,
), ),
@@ -599,14 +598,18 @@ class _ViewTabsSheet extends HookConsumerWidget {
? ViewTabTreesSheetWidget( ? ViewTabTreesSheetWidget(
sheetScrollController: scrollController, sheetScrollController: scrollController,
onClose: () { onClose: () {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
) )
: ViewTabsSheetWidget( : ViewTabsSheetWidget(
sheetScrollController: scrollController, sheetScrollController: scrollController,
draggableScrollableController: draggableScrollableController, draggableScrollableController: draggableScrollableController,
onClose: () { onClose: () {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
), ),
); );
@@ -154,7 +154,9 @@ class BrowserBottomAppBar extends HookConsumerWidget {
isActive: displayedSheet is ViewTabsSheet, isActive: displayedSheet is ViewTabsSheet,
onTap: () { onTap: () {
if (displayedSheet case ViewTabsSheet()) { if (displayedSheet case ViewTabsSheet()) {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
} else { } else {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
@@ -228,7 +228,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
ClipboardData(text: initialTabState.url.toString()), ClipboardData(text: initialTabState.url.toString()),
); );
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
), ),
ListTile( ListTile(
@@ -260,7 +262,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
}, },
); );
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
} }
}, },
), ),
@@ -283,7 +287,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
}, },
); );
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
} }
}, },
), ),
@@ -306,7 +312,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
} }
} }
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
), ),
Consumer( Consumer(
@@ -318,7 +326,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
.read(tabDataRepositoryProvider.notifier) .read(tabDataRepositoryProvider.notifier)
.unassignContainer(initialTabState.id); .unassignContainer(initialTabState.id);
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
), ),
builder: (context, ref, child) { builder: (context, ref, child) {
@@ -340,7 +350,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
ShareParams(uri: initialTabState.url), ShareParams(uri: initialTabState.url),
); );
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
onTapQr: () async { onTapQr: () async {
await showQrCode(context, initialTabState.url.toString()); await showQrCode(context, initialTabState.url.toString());
@@ -376,7 +388,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
}); });
} }
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
}, },
), ),
WebsiteFeedTile(initialTabState), WebsiteFeedTile(initialTabState),
@@ -118,7 +118,7 @@ class SearchScreen extends HookConsumerWidget {
); );
if (context.mounted) { if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
context.pop(); context.pop();
} }
@@ -224,7 +224,7 @@ class SearchScreen extends HookConsumerWidget {
if (context.mounted) { if (context.mounted) {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.dismiss(); .requestDismiss();
context.pop(); context.pop();
} }
@@ -127,7 +127,7 @@ class HistorySuggestions extends HookConsumerWidget {
.read( .read(
bottomSheetControllerProvider.notifier, bottomSheetControllerProvider.notifier,
) )
.dismiss(); .requestDismiss();
context.pop(); context.pop();
} }
@@ -171,7 +171,9 @@ class TabSearch extends HookConsumerWidget {
} }
if (context.mounted) { if (context.mounted) {
ref.read(bottomSheetControllerProvider.notifier).dismiss(); ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
context.pop(); context.pop();
} }