ui refactorings

This commit is contained in:
Fabian Freund
2026-05-29 08:17:08 +02:00
parent 086ae35d15
commit 4a20615842
6 changed files with 320 additions and 250 deletions
@@ -950,7 +950,11 @@ class BrowserScreen extends HookConsumerWidget {
), ),
) )
: Consumer( : Consumer(
builder: (context, ref, _) { // _TabBar is passed via `child` so it is built once and
// reused across toolbar show/hide toggles; only the
// `visible` flag fed to _AnimatedToolbar depends on the
// watched provider.
builder: (context, ref, child) {
final toolbarState = ref.watch( final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId), toolbarVisibilityControllerProvider(selectedTabId),
); );
@@ -961,21 +965,21 @@ class BrowserScreen extends HookConsumerWidget {
return _AnimatedToolbar( return _AnimatedToolbar(
position: TabBarPosition.bottom, position: TabBarPosition.bottom,
visible: visible, visible: visible,
child: child!,
);
},
child: _TabBar( child: _TabBar(
tabBarPosition: TabBarPosition.bottom, tabBarPosition: TabBarPosition.bottom,
showMainToolbar: showMainToolbar:
tabBarPosition == TabBarPosition.bottom, tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar, showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
displayQuickTabSwitcherBar,
isSmallWebMode: false, isSmallWebMode: false,
pointerMoveEvents: pointerMoveEvents:
tabBarPosition == TabBarPosition.bottom tabBarPosition == TabBarPosition.bottom
? pointerMoveEventsController.stream ? pointerMoveEventsController.stream
: null, : null,
), ),
);
},
), ),
), ),
@@ -986,7 +990,11 @@ class BrowserScreen extends HookConsumerWidget {
right: 0, right: 0,
top: 0, top: 0,
child: Consumer( child: Consumer(
builder: (context, ref, _) { // _TabBar is passed via `child` so it is built once and
// reused across toolbar show/hide toggles; only the
// `visible` flag fed to _AnimatedToolbar depends on the
// watched provider.
builder: (context, ref, child) {
final toolbarState = ref.watch( final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId), toolbarVisibilityControllerProvider(selectedTabId),
); );
@@ -997,6 +1005,9 @@ class BrowserScreen extends HookConsumerWidget {
return _AnimatedToolbar( return _AnimatedToolbar(
position: TabBarPosition.top, position: TabBarPosition.top,
visible: visible, visible: visible,
child: child!,
);
},
child: _TabBar( child: _TabBar(
tabBarPosition: TabBarPosition.top, tabBarPosition: TabBarPosition.top,
showMainToolbar: true, showMainToolbar: true,
@@ -1008,8 +1019,6 @@ class BrowserScreen extends HookConsumerWidget {
? null ? null
: pointerMoveEventsController.stream, : pointerMoveEventsController.stream,
), ),
);
},
), ),
), ),
@@ -501,6 +501,57 @@ class QuickTabSwitcherItem with FastEquatable {
this.depth = 0, this.depth = 0,
}); });
/// Builds a switcher entry for an open tab. [sandboxSourceUri] is the
/// canonical source URL when the tab is a sandbox capture (otherwise null),
/// so the bar shows the real site instead of the loopback capture URL.
factory QuickTabSwitcherItem.tab(
TabStateWithContainer state, {
required String? selectedTabId,
required Set<String> pinnedTabIds,
required Map<String, int> tabDepthById,
required Uri? sandboxSourceUri,
}) {
final (tab, container) = state;
return QuickTabSwitcherItem(
color: container?.color,
useCustomColor: container?.metadata.useCustomColor ?? false,
id: tab.id,
isActive: tab.id == selectedTabId,
title: sandboxSourceUri != null && tab.title.isEmpty
? sandboxSourceUri.authority
: tab.titleOrAuthority,
tabMode: tab.tabMode,
isHistory: false,
isPinned: pinnedTabIds.contains(tab.id),
isSandbox: sandboxSourceUri != null,
depth: tabDepthById[tab.id] ?? 0,
url: sandboxSourceUri ?? tab.url,
avatar: TabIcon(tabState: tab, iconSize: 20),
);
}
/// Builds a switcher entry for a history suggestion (shown only when there
/// are no open tabs in the active mode).
factory QuickTabSwitcherItem.history({
required String url,
required String? title,
}) {
final parsedUrl = Uri.parse(url);
return QuickTabSwitcherItem(
color: null,
id: url,
isActive: false,
title: title ?? parsedUrl.authority,
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: parsedUrl,
avatar: UrlIcon([parsedUrl], iconSize: 20),
);
}
@override @override
List<Object?> get hashParameters => [ List<Object?> get hashParameters => [
color, color,
@@ -587,45 +638,25 @@ class QuickTabSwitcher extends HookConsumerWidget {
); );
final reorderEnabled = final reorderEnabled =
effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder; effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder;
final tabItems = tabStates.value.map<QuickTabSwitcherItem>((state) { final tabItems = tabStates.value
final sandboxSourceUri = parseSandboxSource( .map(
sandboxCaptureMap[state.$1.id], (state) => QuickTabSwitcherItem.tab(
);
final displayUrl = sandboxSourceUri ?? state.$1.url;
final displayTitle = sandboxSourceUri != null && state.$1.title.isEmpty
? sandboxSourceUri.authority
: state.$1.titleOrAuthority;
return QuickTabSwitcherItem(
color: state.$2?.color,
useCustomColor: state.$2?.metadata.useCustomColor ?? false,
id: state.$1.id,
isActive: state.$1.id == selectedTabId,
title: displayTitle,
tabMode: state.$1.tabMode,
isHistory: false,
isPinned: pinnedTabIds.contains(state.$1.id),
isSandbox: sandboxSourceUri != null,
depth: tabDepthById[state.$1.id] ?? 0,
url: displayUrl,
avatar: TabIcon(tabState: state.$1, iconSize: 20),
);
}).toList();
final historyItems = (historySuggestions ?? []).map<QuickTabSwitcherItem>((
state, state,
) { selectedTabId: selectedTabId,
final url = Uri.parse(state.url); pinnedTabIds: pinnedTabIds,
return QuickTabSwitcherItem( tabDepthById: tabDepthById,
color: null, sandboxSourceUri: parseSandboxSource(
id: state.url, sandboxCaptureMap[state.$1.id],
isActive: false, ),
title: state.title ?? url.authority, ),
tabMode: TabMode.regular, )
isHistory: true, .toList();
isPinned: false, final historyItems = (historySuggestions ?? [])
url: url, .map(
avatar: UrlIcon([url], iconSize: 20), (visit) =>
); QuickTabSwitcherItem.history(url: visit.url, title: visit.title),
}).toList(); )
.toList();
final availableItems = [...tabItems, ...historyItems]; final availableItems = [...tabItems, ...historyItems];
final activeItem = availableItems.isEmpty final activeItem = availableItems.isEmpty
@@ -192,12 +192,56 @@ class ViewTabTreesWidget extends HookConsumerWidget {
children: [ children: [
TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree), TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree),
Expanded( Expanded(
child: HookConsumer( child: _TabTreesGrid(
builder: (context, ref, child) { scrollController: scrollController,
final screenWidth = MediaQuery.of(context).size.width; onClose: onClose,
final disableAnimations = MediaQuery.disableAnimationsOf( ),
context, ),
],
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
); );
}
}
/// Scrollable grid of tab-tree previews.
///
/// Extracted into its own [HookConsumerWidget] (rather than an inline
/// `HookConsumer`) so the heavy `seamlessFilteredTabEntitiesProvider` watch
/// and the layout/scroll-sync hook state live in a stable, dedicated element
/// instead of an anonymous builder closure. This keeps the surrounding
/// [TabViewHeader] and FAB out of this subtree's rebuild scope.
class _TabTreesGrid extends HookConsumerWidget {
final ScrollController scrollController;
final VoidCallback onClose;
const _TabTreesGrid({required this.scrollController, required this.onClose});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenWidth = MediaQuery.of(context).size.width;
final disableAnimations = MediaQuery.disableAnimationsOf(context);
final containerId = ref.watch(selectedContainerProvider); final containerId = ref.watch(selectedContainerProvider);
@@ -206,9 +250,7 @@ class ViewTabTreesWidget extends HookConsumerWidget {
searchPartition: TabSearchPartition.preview, searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable // ignore: document_ignores using fast equatable
// ignore: provider_parameters // ignore: provider_parameters
containerFilter: ContainerFilterById( containerFilter: ContainerFilterById(containerId: containerId),
containerId: containerId,
),
groupTrees: true, groupTrees: true,
), ),
); );
@@ -223,10 +265,7 @@ class ViewTabTreesWidget extends HookConsumerWidget {
); );
return math.max( return math.max(
math.min( math.min(calculatedCount, filteredTabEntities.value.length),
calculatedCount,
filteredTabEntities.value.length,
),
2, 2,
); );
}, [screenWidth, filteredTabEntities.value.length]); }, [screenWidth, filteredTabEntities.value.length]);
@@ -261,10 +300,7 @@ class ViewTabTreesWidget extends HookConsumerWidget {
scrollController.position.viewportDimension; scrollController.position.viewportDimension;
final targetOffset = final targetOffset =
(tabStart - (tabStart - viewportDimension / 2 + itemSize.height / 2).clamp(
viewportDimension / 2 +
itemSize.height / 2)
.clamp(
0.0, 0.0,
scrollController.position.maxScrollExtent, scrollController.position.maxScrollExtent,
); );
@@ -323,33 +359,5 @@ class ViewTabTreesWidget extends HookConsumerWidget {
}, },
), ),
); );
},
),
),
],
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
} }
} }
@@ -1142,12 +1142,7 @@ class TabViewHeader extends HookConsumerWidget {
), ),
), ),
const Divider(), const Divider(),
if (showContainerUi) if (showContainerUi) _TabFilters(tabsViewMode: tabsViewMode),
Consumer(
builder: (context, ref, child) {
return _TabFilters(tabsViewMode: tabsViewMode);
},
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -52,14 +52,6 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
? selectedContainerTabs.value[selectedContainer.value!] ? selectedContainerTabs.value[selectedContainer.value!]
: null; : null;
return Scaffold(
appBar: AppBar(title: const Text('Draft Containers')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: suggestionsAsync.when(
skipLoadingOnReload: true,
data: (suggestions) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final crossAxisCount = useMemoized(() { final crossAxisCount = useMemoized(() {
@@ -78,6 +70,14 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
); );
}, [screenWidth, selectedContainer.value?.tabIds.length]); }, [screenWidth, selectedContainer.value?.tabIds.length]);
return Scaffold(
appBar: AppBar(title: const Text('Draft Containers')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: suggestionsAsync.when(
skipLoadingOnReload: true,
data: (suggestions) {
return Column( return Column(
children: [ children: [
SizedBox( SizedBox(
@@ -88,35 +88,10 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
itemId: (container) => container, itemId: (container) => container,
itemAvatar: (container) => itemAvatar: (container) =>
const Icon(MdiIcons.creation, size: 20), const Icon(MdiIcons.creation, size: 20),
itemLabel: (container) => HookConsumer( itemLabel: (container) => _SuggestedContainerLabel(
builder: (context, ref, child) { key: ValueKey(container),
final items = useListenableSelector( container: container,
selectedContainerTabs, selectedContainerTabs: selectedContainerTabs,
() => selectedContainerTabs.value[container],
);
final topic = items.isNotEmpty
? ref.watch(
tabsTopicProvider(EquatableValue(items!)),
)
: AsyncValue.data(container.topic);
return topic.when(
skipLoadingOnReload: true,
data: (topic) => Text(topic ?? 'Untitled'),
error: (error, stackTrace) {
logger.e(
'Failed predicting selected tabs topic',
error: error,
stackTrace: stackTrace,
);
return Text(container.topic ?? 'Untitled');
},
loading: () =>
const Skeletonizer(child: Text('Untitled')),
);
},
), ),
itemBadgeCount: (container) => container.tabIds.length, itemBadgeCount: (container) => container.tabIds.length,
availableItems: suggestions!, availableItems: suggestions!,
@@ -237,3 +212,48 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
); );
} }
} }
/// Label for a single suggested-container chip.
///
/// Extracted into its own [HookConsumerWidget] (rather than an inline
/// `HookConsumer` in `itemLabel`) so its hook state is bound to a stable
/// element identity per container, and the heavy `tabsTopicProvider` watch
/// is scoped to just this chip.
class _SuggestedContainerLabel extends HookConsumerWidget {
final SuggestedContainer container;
final ValueNotifier<Map<SuggestedContainer, Set<String>>>
selectedContainerTabs;
const _SuggestedContainerLabel({
required this.container,
required this.selectedContainerTabs,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = useListenableSelector(
selectedContainerTabs,
() => selectedContainerTabs.value[container],
);
final topic = items.isNotEmpty
? ref.watch(tabsTopicProvider(EquatableValue(items!)))
: AsyncValue.data(container.topic);
return topic.when(
skipLoadingOnReload: true,
data: (topic) => Text(topic ?? 'Untitled'),
error: (error, stackTrace) {
logger.e(
'Failed predicting selected tabs topic',
error: error,
stackTrace: stackTrace,
);
return Text(container.topic ?? 'Untitled');
},
loading: () => const Skeletonizer(child: Text('Untitled')),
);
}
}
@@ -145,6 +145,11 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final GlobalKey? activeItemKey; final GlobalKey? activeItemKey;
final double? cacheExtent; final double? cacheExtent;
/// Key applied to the underlying scroll view. Supply a [PageStorageKey] to
/// preserve the scroll offset across rebuilds/remounts (e.g. when a host
/// widget is torn down and recreated by a bottom sheet open/close).
final Key? scrollKey;
final K Function(S item) itemId; final K Function(S item) itemId;
final Widget Function(T item) itemLabel; final Widget Function(T item) itemLabel;
final Widget? Function(T item)? itemAvatar; final Widget? Function(T item)? itemAvatar;
@@ -187,6 +192,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
this.scrollController, this.scrollController,
this.activeItemKey, this.activeItemKey,
this.cacheExtent = 0, this.cacheExtent = 0,
this.scrollKey,
super.key, super.key,
}); });
@@ -296,6 +302,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
builder: (context, controller) { builder: (context, controller) {
if (onReorder == null) { if (onReorder == null) {
return ListView.builder( return ListView.builder(
key: scrollKey,
controller: controller, controller: controller,
scrollCacheExtent: cacheExtent.mapNotNull( scrollCacheExtent: cacheExtent.mapNotNull(
(extent) => ScrollCacheExtent.pixels(extent), (extent) => ScrollCacheExtent.pixels(extent),