bottom sheet redesign

This commit is contained in:
Fabian Freund
2026-05-31 11:42:07 +02:00
parent 4d4c8a8786
commit 8277be500d
@@ -110,6 +110,9 @@ class _BrowserMenuSheet extends HookConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final selectedTabId = ref.watch(selectedTabProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final gesturesEnabled = ref.watch(
gestureSettingsWithDefaultsProvider.select((s) => s.enabled),
);
return DraggableScrollableSheet(
initialChildSize: 0.85,
@@ -143,8 +146,8 @@ class _BrowserMenuSheet extends HookConsumerWidget {
vertical: 8,
),
children: [
// Quick toggles (Desktop Mode / Reader Mode)
if (selectedTabId != null) ...[
// Quick toggles (Desktop / Reader / Gestures)
if (selectedTabId != null || gesturesEnabled) ...[
_QuickTogglesGrid(selectedTabId: selectedTabId),
const SizedBox(height: 16),
],
@@ -171,20 +174,14 @@ class _BrowserMenuSheet extends HookConsumerWidget {
),
const SizedBox(height: 16),
// Connection (Tor + proxy profiles)
const _ConnectionCard(),
const SizedBox(height: 16),
// Profile
_ProfileCard(),
const SizedBox(height: 16),
// Gestures quick toggle (only when the master switch is on)
if (ref.watch(
gestureSettingsWithDefaultsProvider.select(
(s) => s.enabled,
),
)) ...[
const _GestureToggleTile(),
const SizedBox(height: 16),
],
// App
const _SettingsCard(),
const SizedBox(height: 24),
@@ -585,136 +582,210 @@ class _PageActionsCard extends HookConsumerWidget {
// ─── Quick Toggles Grid ───
class _QuickTogglesGrid extends HookConsumerWidget {
final String selectedTabId;
/// Unified bar of quick toggles. Page-scoped toggles (Desktop / Reader) appear
/// only when a tab is selected; the global Gestures toggle appears whenever the
/// gesture master switch is on. They are laid out as a connected, equal-width
/// segmented bar (icon over label) that wraps to a new row past four toggles.
class _QuickTogglesGrid extends ConsumerWidget {
final String? selectedTabId;
const _QuickTogglesGrid({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final desktopEnabled = ref.watch(desktopModeProvider(selectedTabId));
final gestureSettings = ref.watch(gestureSettingsWithDefaultsProvider);
final readerChanging = ref.watch(readerableScreenControllerProvider);
final readerabilityState = ref.watch(
selectedTabStateProvider.select(
(state) => state?.readerableState ?? ReaderableState.$default(),
),
);
final isReaderActive = readerabilityState.active;
final isReaderLoading = readerChanging.isLoading;
final toggles = <_QuickToggle>[];
final enableReadability = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.enableReadability,
),
);
final enforceReadability = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.enforceReadability,
),
);
final readerVisible =
(readerabilityState.readerable &&
(enableReadability || readerabilityState.active)) ||
(enforceReadability && enableReadability);
return Row(
children: [
Expanded(
child: _ToggleTile(
icon: MdiIcons.monitor,
label: 'Desktop Mode',
active: desktopEnabled,
onTap: () {
ref
.read(desktopModeProvider(selectedTabId).notifier)
.enabled(!desktopEnabled);
},
),
if (selectedTabId case final tabId?) {
final desktopEnabled = ref.watch(desktopModeProvider(tabId));
toggles.add(
_QuickToggle(
icon: MdiIcons.monitor,
label: 'Desktop',
active: desktopEnabled,
onTap: () {
ref
.read(desktopModeProvider(tabId).notifier)
.enabled(!desktopEnabled);
},
),
const SizedBox(width: 12),
Expanded(
child: readerVisible
? _ToggleTile(
icon: isReaderActive
? MdiIcons.bookOpen
: MdiIcons.bookOpenOutline,
label: 'Reader Mode',
active: isReaderActive,
enabled: !isReaderLoading,
onTap: () async {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!isReaderActive);
},
)
: _ToggleTile(
icon: MdiIcons.bookOpenOutline,
label: 'Reader Mode',
active: false,
enabled: false,
onTap: () {},
),
);
final isReaderLoading = ref
.watch(readerableScreenControllerProvider)
.isLoading;
final readerabilityState = ref.watch(
selectedTabStateProvider.select(
(state) => state?.readerableState ?? ReaderableState.$default(),
),
],
);
);
final isReaderActive = readerabilityState.active;
final enableReadability = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.enableReadability,
),
);
final enforceReadability = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.enforceReadability,
),
);
final readerVisible =
(readerabilityState.readerable &&
(enableReadability || readerabilityState.active)) ||
(enforceReadability && enableReadability);
toggles.add(
_QuickToggle(
icon: (readerVisible && isReaderActive)
? MdiIcons.bookOpen
: MdiIcons.bookOpenOutline,
label: 'Reader',
active: readerVisible && isReaderActive,
enabled: readerVisible && !isReaderLoading,
onTap: () async {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!isReaderActive);
},
),
);
}
if (gestureSettings.enabled) {
toggles.add(
_QuickToggle(
icon: MdiIcons.gestureSwipe,
label: 'Gestures',
active: gestureSettings.active,
onTap: () async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(s) => s.copyWith(active: !gestureSettings.active),
);
},
),
);
}
if (toggles.isEmpty) return const SizedBox.shrink();
return _QuickToggleBar(toggles: toggles);
}
}
class _ToggleTile extends StatelessWidget {
/// A single quick toggle's display + behavior, rendered as one segment of a
/// [_QuickToggleBar].
class _QuickToggle {
final IconData icon;
final String label;
final bool active;
final bool enabled;
final VoidCallback onTap;
const _ToggleTile({
const _QuickToggle({
required this.icon,
required this.label,
required this.active,
this.enabled = true,
required this.onTap,
this.enabled = true,
});
}
/// Connected, equal-width segmented bar (icon over label) for the quick
/// toggles. Segments are split across rows of at most [_maxPerRow] so the bar
/// stays compact and resizes to however many toggles are present.
class _QuickToggleBar extends StatelessWidget {
final List<_QuickToggle> toggles;
static const int _maxPerRow = 4;
const _QuickToggleBar({required this.toggles});
@override
Widget build(BuildContext context) {
final rows = <List<_QuickToggle>>[];
for (var i = 0; i < toggles.length; i += _maxPerRow) {
final end = i + _maxPerRow <= toggles.length
? i + _maxPerRow
: toggles.length;
rows.add(toggles.sublist(i, end));
}
return Column(
children: [
for (var r = 0; r < rows.length; r++) ...[
if (r > 0) const SizedBox(height: 8),
_buildRow(context, rows[r]),
],
],
);
}
Widget _buildRow(BuildContext context, List<_QuickToggle> items) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
Expanded(child: _QuickToggleSegment(toggle: items[i])),
],
],
),
),
);
}
}
class _QuickToggleSegment extends StatelessWidget {
final _QuickToggle toggle;
const _QuickToggleSegment({required this.toggle});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final backgroundColor = active
? colorScheme.primary
: colorScheme.surfaceContainerHigh;
final foregroundColor = active
? colorScheme.onPrimary
: colorScheme.onSurface;
final effectiveAlpha = enabled ? 1.0 : 0.38;
final foregroundColor = !toggle.enabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: toggle.active
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant;
return Material(
color: backgroundColor.withValues(alpha: enabled ? 1.0 : 0.5),
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
color: toggle.active
? colorScheme.secondaryContainer
: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(28),
onTap: toggle.enabled ? toggle.onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
child: Row(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: foregroundColor.withValues(alpha: effectiveAlpha),
size: 22,
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
color: foregroundColor.withValues(alpha: effectiveAlpha),
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Icon(toggle.icon, color: foregroundColor, size: 22),
const SizedBox(height: 6),
Text(
toggle.label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
],
@@ -944,34 +1015,6 @@ class _FetchFeedsTile extends HookConsumerWidget {
// ─── Gestures Quick Toggle ───
class _GestureToggleTile extends ConsumerWidget {
const _GestureToggleTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final active = ref.watch(
gestureSettingsWithDefaultsProvider.select((s) => s.active),
);
return _buildMenuCard(
context,
children: [
SwitchListTile.adaptive(
secondary: const Icon(MdiIcons.gestureSwipe),
title: const Text('Gestures'),
subtitle: Text(active ? 'Active' : 'Suspended'),
value: active,
onChanged: (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings((s) => s.copyWith(active: value));
},
),
],
);
}
}
// ─── Tab Actions Card ───
class _TabActionsCard extends HookConsumerWidget {
@@ -1966,12 +2009,13 @@ class _ExtensionsCard extends HookConsumerWidget {
}
}
// ─── Quick Links Grid ───
// ─── Connection Card ───
class _QuickLinksGrid extends ConsumerWidget {
final bool showContainerUi;
const _QuickLinksGrid({required this.showContainerUi});
/// Tor and assigned sing-box proxy connections. Each row toggles its connection
/// on tap and opens the corresponding editor via the trailing chevron, keeping
/// these connection *state* controls out of the navigation-only Quick Links.
class _ConnectionCard extends ConsumerWidget {
const _ConnectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -1996,8 +2040,142 @@ class _QuickLinksGrid extends ConsumerWidget {
)
.value;
final torActiveColor = AppColors.of(context).torActiveGreen;
final rows = <Widget>[
_ConnectionRow(
icon: TorIcons.onionAlt,
label: 'Tor™ Proxy',
active: isTorActive,
enabled: !isTorBusy,
onToggle: () async {
if (isTorBusy) return;
if (isTorActive) {
await ref.read(torProxyServiceProvider.notifier).disconnect();
} else {
await ref.read(startProxyControllerProvider.notifier).startProxy();
}
},
onEdit: () async {
Navigator.pop(context);
await const TorProxyRoute().push(context);
},
),
for (final profile in assignedProfiles)
_ConnectionRow(
icon: MdiIcons.lanConnect,
label: profile.name,
active: runtimeEndpointIds.contains(profile.proxyConnectionId),
onToggle: () async {
final runtime = ref.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
final isRunning = runtimeEndpointIds.contains(
profile.proxyConnectionId,
);
try {
if (isRunning) {
await runtime.stopProfiles([profile.id]);
} else {
await runtime.startProfile(profile.id);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle proxy profile ${profile.id} from menu',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Proxy error: $error');
}
}
},
onEdit: () async {
Navigator.pop(context);
await SingboxProxyProfileEditorRoute(
profileId: profile.id,
).push(context);
},
),
];
return _buildMenuCard(
context,
children: [
for (var i = 0; i < rows.length; i++) ...[
if (i > 0) _buildDivider(),
rows[i],
],
],
);
}
}
class _ConnectionRow extends StatelessWidget {
final IconData icon;
final String label;
final bool active;
final bool enabled;
final Future<void> Function() onToggle;
final Future<void> Function() onEdit;
const _ConnectionRow({
required this.icon,
required this.label,
required this.active,
required this.onToggle,
required this.onEdit,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final activeColor = AppColors.of(context).torActiveGreen;
return ListTile(
// Trim the right padding by the IconButton's internal inset (~12px) so the
// edit chevron's glyph lands at the same 16px from the edge as the plain
// trailing icons used by the Share/More/Containers rows, while keeping the
// button's full 48px touch target.
contentPadding: const EdgeInsets.only(left: 16, right: 16),
leading: Icon(
icon,
color: active ? activeColor : colorScheme.onSurfaceVariant,
),
title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(active ? 'Connected' : 'Off'),
onTap: enabled ? () => onToggle() : null,
dense: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? Icons.circle : Icons.circle_outlined,
size: 12,
color: active ? activeColor : colorScheme.outline,
),
const SizedBox(width: 16),
const VerticalDivider(indent: 4, endIndent: 4),
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: 'Edit',
onPressed: () => onEdit(),
),
],
),
);
}
}
// ─── Quick Links Grid ───
class _QuickLinksGrid extends ConsumerWidget {
final bool showContainerUi;
const _QuickLinksGrid({required this.showContainerUi});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = <_QuickLinkItem>[
_QuickLinkItem(
icon: Icons.history,
@@ -2049,62 +2227,6 @@ class _QuickLinksGrid extends ConsumerWidget {
await ref.read(smallWebModeControllerProvider.notifier).enter();
},
),
_QuickLinkItem(
icon: TorIcons.onionAlt,
label: 'Tor\u2122 Proxy',
badge: isTorActive,
badgeColor: torActiveColor,
onTap: () async {
if (isTorBusy) return;
if (isTorActive) {
await ref.read(torProxyServiceProvider.notifier).disconnect();
} else {
await ref.read(startProxyControllerProvider.notifier).startProxy();
}
},
onLongPress: () async {
Navigator.pop(context);
await const TorProxyRoute().push(context);
},
),
for (final profile in assignedProfiles)
_QuickLinkItem(
icon: MdiIcons.lanConnect,
label: profile.name,
badge: runtimeEndpointIds.contains(profile.proxyConnectionId),
badgeColor: torActiveColor,
onTap: () async {
final runtime = ref.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
final isRunning = runtimeEndpointIds.contains(
profile.proxyConnectionId,
);
try {
if (isRunning) {
await runtime.stopProfiles([profile.id]);
} else {
await runtime.startProfile(profile.id);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle proxy profile ${profile.id} from menu',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Proxy error: $error');
}
}
},
onLongPress: () async {
Navigator.pop(context);
await SingboxProxyProfileEditorRoute(
profileId: profile.id,
).push(context);
},
),
];
const spacing = 8.0;
@@ -2126,9 +2248,6 @@ class _QuickLinksGrid extends ConsumerWidget {
item.icon,
item.label,
item.onTap,
badge: item.badge,
badgeColor: item.badgeColor,
onLongPress: item.onLongPress,
),
),
],
@@ -2141,33 +2260,21 @@ class _QuickLinksGrid extends ConsumerWidget {
BuildContext context,
IconData icon,
String label,
VoidCallback onTap, {
bool badge = false,
Color? badgeColor,
VoidCallback? onLongPress,
}) {
final iconWidget = Icon(
icon,
color: Theme.of(context).colorScheme.onSurface,
);
VoidCallback onTap,
) {
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (badge)
Badge(backgroundColor: badgeColor, child: iconWidget)
else
iconWidget,
Icon(icon, color: Theme.of(context).colorScheme.onSurface),
const SizedBox(height: 8),
Text(
label,
@@ -2189,17 +2296,11 @@ class _QuickLinkItem {
final IconData icon;
final String label;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final bool badge;
final Color? badgeColor;
const _QuickLinkItem({
required this.icon,
required this.label,
required this.onTap,
this.onLongPress,
this.badge = false,
this.badgeColor,
});
}