add feature container drafting

This commit is contained in:
Fabian Freund
2025-10-01 12:58:53 +02:00
parent 84f574634c
commit 6ae4031806
6 changed files with 332 additions and 123 deletions
+11
View File
@@ -33,6 +33,10 @@ part of 'routes.dart';
name: 'ContextMenuRoute',
path: 'context_menu',
),
TypedGoRoute<ContainerDraftRoute>(
name: 'ContainerDraftRoute',
path: 'container_draft',
),
TypedGoRoute<ContainerListRoute>(
name: 'ContainerListRoute',
path: 'containers',
@@ -107,6 +111,13 @@ class TorProxyRoute extends GoRouteData with $TorProxyRoute {
}
}
class ContainerDraftRoute extends GoRouteData with $ContainerDraftRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerDraftSuggestionsScreen();
}
}
class ContainerListRoute extends GoRouteData with $ContainerListRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
+1
View File
@@ -34,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/co
import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
+26
View File
@@ -322,6 +322,11 @@ RouteBase get $browserRoute => GoRouteData.$route(
name: 'ContextMenuRoute',
factory: $ContextMenuRoute._fromState,
),
GoRouteData.$route(
path: 'container_draft',
name: 'ContainerDraftRoute',
factory: $ContainerDraftRoute._fromState,
),
GoRouteData.$route(
path: 'containers',
name: 'ContainerListRoute',
@@ -487,6 +492,27 @@ mixin $ContextMenuRoute on GoRouteData {
context.replace(location, extra: _self.$extra);
}
mixin $ContainerDraftRoute on GoRouteData {
static ContainerDraftRoute _fromState(GoRouterState state) =>
ContainerDraftRoute();
@override
String get location => GoRouteData.$location('/container_draft');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $ContainerListRoute on GoRouteData {
static ContainerListRoute _fromState(GoRouterState state) =>
ContainerListRoute();
@@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider
}
String _$geckoInferenceRepositoryHash() =>
r'eb174c9f80f3537bc075ebbffcef787ec55ad217';
r'5f99fd1e46a8ae291b63df044fe11917cd5d415c';
abstract class _$GeckoInferenceRepository extends $Notifier<void> {
void build();
@@ -138,13 +138,13 @@ const suggestClustersProvider = SuggestClustersProvider._();
final class SuggestClustersProvider
extends
$FunctionalProvider<
AsyncValue<List<({List<String> tabIds, String? topic})>?>,
List<({List<String> tabIds, String? topic})>?,
FutureOr<List<({List<String> tabIds, String? topic})>?>
AsyncValue<List<SuggestedContainer>?>,
List<SuggestedContainer>?,
FutureOr<List<SuggestedContainer>?>
>
with
$FutureModifier<List<({List<String> tabIds, String? topic})>?>,
$FutureProvider<List<({List<String> tabIds, String? topic})>?> {
$FutureModifier<List<SuggestedContainer>?>,
$FutureProvider<List<SuggestedContainer>?> {
const SuggestClustersProvider._()
: super(
from: null,
@@ -161,16 +161,17 @@ final class SuggestClustersProvider
@$internal
@override
$FutureProviderElement<List<({List<String> tabIds, String? topic})>?>
$createElement($ProviderPointer pointer) => $FutureProviderElement(pointer);
$FutureProviderElement<List<SuggestedContainer>?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<({List<String> tabIds, String? topic})>?> create(Ref ref) {
FutureOr<List<SuggestedContainer>?> create(Ref ref) {
return suggestClusters(ref);
}
}
String _$suggestClustersHash() => r'592058ed092384c37a185dcd5657276a3dde5b4e';
String _$suggestClustersHash() => r'3b7da171b3596f708a8039658578251ae61655ea';
@ProviderFor(containerTabSuggestions)
const containerTabSuggestionsProvider = ContainerTabSuggestionsFamily._();
@@ -0,0 +1,176 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
const ContainerDraftSuggestionsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final suggestionsAsync = ref.watch(suggestClustersProvider);
final selectedContainer = useState<SuggestedContainer?>(null);
final selectedContainerTabs = useState(<SuggestedContainer, Set<String>>{});
final selectedTabs = selectedContainer.value != null
? selectedContainerTabs.value[selectedContainer.value!]
: null;
return Scaffold(
appBar: AppBar(title: const Text('Draft Containers')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: suggestionsAsync.when(
data: (suggestions) {
final screenWidth = MediaQuery.of(context).size.width;
final crossAxisCount = useMemoized(() {
final calculatedCount = calculateCrossAxisItemCount(
screenWidth: screenWidth,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(
math.min(
calculatedCount,
selectedContainer.value?.tabIds.length ?? 0,
),
2,
);
}, [screenWidth, selectedContainer.value?.tabIds.length]);
return Column(
children: [
SizedBox(
height: 48,
child: SelectableChips(
deleteIcon: false,
itemId: (container) => container,
itemAvatar: (container) =>
const Icon(MdiIcons.creation, size: 20),
itemLabel: (container) =>
Text(container.topic ?? 'Untitled'),
itemBadgeCount: (container) => container.tabIds.length,
availableItems: suggestions!,
selectedItem: selectedContainer.value,
onSelected: (item) {
selectedContainer.value = item;
},
),
),
const Divider(),
const SizedBox(height: 4),
if (selectedContainer.value != null)
Expanded(
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: selectedContainer.value!.tabIds.length,
itemBuilder: (context, index) {
final tabId = selectedContainer.value!.tabIds[index];
final equatable = selectedContainer.value!;
return (selectedContainerTabs.value[equatable]
?.contains(tabId) ==
true)
? TabPreview(
tabId: tabId,
isActive: false,
onDelete: () {
selectedContainerTabs.value = {
...selectedContainerTabs.value,
equatable: {
...?selectedContainerTabs
.value[equatable],
}..remove(tabId),
};
},
)
: SuggestedSingleTabPreview(
tabId: tabId,
activeTabId: null,
onTap: () {
selectedContainerTabs.value = {
...selectedContainerTabs.value,
equatable: {
...?selectedContainerTabs
.value[equatable],
tabId,
},
};
},
);
},
),
),
],
);
},
error: (error, stackTrace) {
return FailureWidget(
title: 'Failed to create suggestions',
onRetry: () {
ref.invalidate(suggestClustersProvider);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
),
),
),
floatingActionButton: (selectedTabs?.isNotEmpty ?? false)
? FloatingActionButton(
child: const Icon(MdiIcons.folderPlus),
onPressed: () async {
final initialColor = await ref
.read(containerRepositoryProvider.notifier)
.unusedRandomContainerColor();
final initialContainer = ContainerData(
id: uuid.v7(),
color: initialColor,
name: selectedContainer.value?.topic,
);
if (context.mounted) {
final result = await ContainerCreateRoute(
initialContainer,
).push<ContainerData?>(context);
if (result != null) {
for (final tab in selectedTabs!) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(tab, result);
}
selectedContainerTabs.value = {};
selectedContainer.value = null;
}
}
},
)
: null,
);
}
}
@@ -23,6 +23,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart';
@@ -85,126 +86,119 @@ class ContainerChips extends HookConsumerWidget {
height: 48,
child: Row(
children: [
if (selectedContainer != null || availableContainers.isNotEmpty)
Expanded(
child: SelectableChips(
deleteIcon: false,
itemId: (container) => container.id,
itemAvatar: (container) => Container(
width: 20.0,
height: 20.0,
decoration: BoxDecoration(
color: container.color,
shape: BoxShape.circle,
Expanded(
child: SelectableChips(
deleteIcon: false,
itemId: (container) => container.id,
itemAvatar: (container) => Container(
width: 20.0,
height: 20.0,
decoration: BoxDecoration(
color: container.color,
shape: BoxShape.circle,
),
),
itemLabel: (container) =>
ContainerTitle(container: container),
itemBadgeCount: (container) => container.tabCount,
itemWrap: (child, container) {
return TabDragContainerTarget(
container: container,
child: child,
);
},
prefixListItems: [
TabDragContainerTarget(
container: null,
child: Consumer(
builder: (context, ref, child) {
final tabCount = ref.watch(
containerTabCountProvider(
// ignore: provider_parameters
ContainerFilterById(containerId: null),
).select((value) => value.value ?? 0),
);
return FilterChip(
avatar: const Icon(MdiIcons.folderHidden),
labelPadding: (tabCount > 0)
? null
: const EdgeInsets.only(right: 2.0),
label: (tabCount > 0)
? Text(tabCount.toString())
: const SizedBox.shrink(),
selected: selectedContainer == null,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected?.call(null);
}
},
);
},
),
),
itemLabel: (container) =>
ContainerTitle(container: container),
itemBadgeCount: (container) => container.tabCount,
itemWrap: (child, container) {
return TabDragContainerTarget(
container: container,
child: child,
);
},
prefixListItems: [
TabDragContainerTarget(
container: null,
child: Consumer(
builder: (context, ref, child) {
final tabCount = ref.watch(
containerTabCountProvider(
// ignore: provider_parameters
ContainerFilterById(containerId: null),
).select((value) => value.value ?? 0),
);
if (showGroupSuggestions)
Consumer(
builder: (context, ref, child) {
final tabSuggestionsEnabled = ref.watch(
tabSuggestionsControllerProvider,
);
final enableAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
return FilterChip(
avatar: const Icon(MdiIcons.folderHidden),
labelPadding: (tabCount > 0)
? null
: const EdgeInsets.only(right: 2.0),
label: (tabCount > 0)
? Text(tabCount.toString())
: const SizedBox.shrink(),
selected: selectedContainer == null,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected?.call(null);
}
},
);
},
),
),
if (showGroupSuggestions)
Consumer(
builder: (context, ref, child) {
final tabSuggestionsEnabled = ref.watch(
tabSuggestionsControllerProvider,
);
final enableAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
if (!enableAiFeatures || !tabSuggestionsEnabled) {
return const SizedBox.shrink();
}
if (!enableAiFeatures || !tabSuggestionsEnabled) {
final suggestions = ref.watch(
suggestClustersProvider,
);
return suggestions.when(
data: (data) {
if (data.isEmpty) {
return const SizedBox.shrink();
}
return FilterChip(
avatar: const Icon(MdiIcons.autoFix),
label: Text(data!.length.toString()),
showCheckmark: false,
onSelected: (_) async {
await ContainerDraftRoute().push(context);
},
);
},
error: (error, stackTrace) {
logger.e(
'Error suggesting containers',
error: error,
stackTrace: stackTrace,
);
return const SizedBox.shrink();
}
final suggestions = ref.watch(
suggestClustersProvider,
);
return suggestions.when(
data: (data) {
if (data.isEmpty) {
return const SizedBox.shrink();
}
return FilterChip(
avatar: const Icon(MdiIcons.autoFix),
label: Text(data!.length.toString()),
showCheckmark: false,
onSelected: (value) {},
);
},
error: (error, stackTrace) {
logger.e(
'Error suggesting containers',
error: error,
stackTrace: stackTrace,
);
return const SizedBox.shrink();
},
loading: () {
return const SizedBox.shrink();
},
);
},
),
],
availableItems: availableContainers,
selectedItem: selectedContainer,
onSelected: onSelected,
onDeleted: onDeleted,
),
)
else
Visibility(
visible: displayMenu,
child: Expanded(
child: Text(
"Press '>' to manage Containers.",
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
},
loading: () {
return const FilterChip(
avatar: Icon(MdiIcons.autoFix),
label: Skeletonizer(child: Text('0')),
showCheckmark: false,
onSelected: null,
);
},
);
},
),
),
),
],
availableItems: availableContainers,
selectedItem: selectedContainer,
onSelected: onSelected,
onDeleted: onDeleted,
),
),
if (displayMenu)
IconButton(
visualDensity: VisualDensity.compact,