diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 7e4dcd7f..71d9f0d7 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -1143,6 +1143,13 @@ RouteBase get $historyRoute => GoRouteData.$route( path: '/history', name: 'HistoryRoute', factory: $HistoryRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'downloads', + name: 'HistoryDownloadsRoute', + factory: $HistoryDownloadsRoute._fromState, + ), + ], ); mixin $HistoryRoute on GoRouteData { @@ -1165,6 +1172,27 @@ mixin $HistoryRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $HistoryDownloadsRoute on GoRouteData { + static HistoryDownloadsRoute _fromState(GoRouterState state) => + const HistoryDownloadsRoute(); + + @override + String get location => GoRouteData.$location('/history/downloads'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $profileListRoute => GoRouteData.$route( path: '/profiles', name: 'ProfileListRoute', diff --git a/app/lib/core/routing/routes.history.dart b/app/lib/core/routing/routes.history.dart index 3e5bc88c..558480b2 100644 --- a/app/lib/core/routing/routes.history.dart +++ b/app/lib/core/routing/routes.history.dart @@ -19,7 +19,16 @@ */ part of 'routes.dart'; -@TypedGoRoute(name: 'HistoryRoute', path: '/history') +@TypedGoRoute( + name: 'HistoryRoute', + path: '/history', + routes: [ + TypedGoRoute( + name: 'HistoryDownloadsRoute', + path: 'downloads', + ), + ], +) class HistoryRoute extends GoRouteData with $HistoryRoute { const HistoryRoute(); @@ -28,3 +37,12 @@ class HistoryRoute extends GoRouteData with $HistoryRoute { return const HistoryScreen(); } } + +class HistoryDownloadsRoute extends GoRouteData with $HistoryDownloadsRoute { + const HistoryDownloadsRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const HistoryScreen(mode: HistoryScreenMode.downloads); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart index 5066b511..543e2989 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart @@ -54,12 +54,12 @@ class BrowserNavigationDrawer extends HookConsumerWidget { return NavigationDrawer( backgroundColor: colorScheme.surface, + header: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [_ProfileHeader(), _SyncTile(), const Divider()], + ), children: [ - // Profile Header - _ProfileHeader(), - _SyncTile(), - const Divider(), - // Section 1: Tools & Configuration _ExtensionsSection(), @@ -107,6 +107,15 @@ class BrowserNavigationDrawer extends HookConsumerWidget { }, ), + ListTile( + leading: const Icon(MdiIcons.fileDownload), + title: const Text('Downloads'), + onTap: () async { + Navigator.of(context).pop(); + await const HistoryDownloadsRoute().push(context); + }, + ), + ListTile( leading: const Icon(MdiIcons.bookmarkMultiple), title: const Text('Bookmarks'), diff --git a/app/lib/features/geckoview/features/history/domain/providers.dart b/app/lib/features/geckoview/features/history/domain/providers.dart index 991554a8..9b7a6d8c 100644 --- a/app/lib/features/geckoview/features/history/domain/providers.dart +++ b/app/lib/features/geckoview/features/history/domain/providers.dart @@ -30,7 +30,7 @@ part 'providers.g.dart'; @Riverpod(keepAlive: true) @JsonPersist() -class HistoryFilter extends _$HistoryFilter { +class HistoryVisitsFilter extends _$HistoryVisitsFilter { void updateVisitType(VisitType type, bool value) { if (value) { state = state.copyWith.visitTypes({...state.visitTypes, type}); @@ -51,16 +51,54 @@ class HistoryFilter extends _$HistoryFilter { HistoryFilterOptions build() { persist( ref.watch(riverpodDatabaseStorageProvider), - key: 'HistoryFilterOptions', + key: 'HistoryVisitsFilterOptions', ); return stateOrNull ?? HistoryFilterOptions.withDefaults(); } } +@Riverpod(keepAlive: true) +@JsonPersist() +class HistoryDownloadsFilter extends _$HistoryDownloadsFilter { + void reset() { + state = HistoryFilterOptions( + dateRange: null, + visitTypes: const {VisitType.download}, + ); + } + + void setDateRange(DateTimeRange? range) { + state = state.copyWith.dateRange(range); + } + + @override + HistoryFilterOptions build() { + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'HistoryDownloadsFilterOptions', + ); + + return stateOrNull ?? + HistoryFilterOptions( + dateRange: null, + visitTypes: const {VisitType.download}, + ); + } +} + @Riverpod() Future> browsingHistory(Ref ref) { - final options = ref.watch(historyFilterProvider); + final options = ref.watch(historyVisitsFilterProvider); + + return ref + .read(historyRepositoryProvider.notifier) + .getDetailedVisits(options); +} + +@Riverpod() +Future> browsingDownloads(Ref ref) { + final options = ref.watch(historyDownloadsFilterProvider); return ref .read(historyRepositoryProvider.notifier) diff --git a/app/lib/features/geckoview/features/history/domain/providers.g.dart b/app/lib/features/geckoview/features/history/domain/providers.g.dart index 2d6cd9ab..accf9742 100644 --- a/app/lib/features/geckoview/features/history/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/history/domain/providers.g.dart @@ -9,30 +9,30 @@ part of 'providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(HistoryFilter) +@ProviderFor(HistoryVisitsFilter) @JsonPersist() -final historyFilterProvider = HistoryFilterProvider._(); +final historyVisitsFilterProvider = HistoryVisitsFilterProvider._(); @JsonPersist() -final class HistoryFilterProvider - extends $NotifierProvider { - HistoryFilterProvider._() +final class HistoryVisitsFilterProvider + extends $NotifierProvider { + HistoryVisitsFilterProvider._() : super( from: null, argument: null, retry: null, - name: r'historyFilterProvider', + name: r'historyVisitsFilterProvider', isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$historyFilterHash(); + String debugGetCreateSourceHash() => _$historyVisitsFilterHash(); @$internal @override - HistoryFilter create() => HistoryFilter(); + HistoryVisitsFilter create() => HistoryVisitsFilter(); /// {@macro riverpod.override_with_value} Override overrideWithValue(HistoryFilterOptions value) { @@ -43,10 +43,69 @@ final class HistoryFilterProvider } } -String _$historyFilterHash() => r'685b92590011ec450ef0c19820e4a6750d26b79d'; +String _$historyVisitsFilterHash() => + r'a4bf6c41c9180166365084cb7c7981558ac7ad36'; @JsonPersist() -abstract class _$HistoryFilterBase extends $Notifier { +abstract class _$HistoryVisitsFilterBase + extends $Notifier { + HistoryFilterOptions build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + HistoryFilterOptions, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(HistoryDownloadsFilter) +@JsonPersist() +final historyDownloadsFilterProvider = HistoryDownloadsFilterProvider._(); + +@JsonPersist() +final class HistoryDownloadsFilterProvider + extends $NotifierProvider { + HistoryDownloadsFilterProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'historyDownloadsFilterProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$historyDownloadsFilterHash(); + + @$internal + @override + HistoryDownloadsFilter create() => HistoryDownloadsFilter(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(HistoryFilterOptions value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$historyDownloadsFilterHash() => + r'b44de5aced7dadba361fddf69b8462ff92252a99'; + +@JsonPersist() +abstract class _$HistoryDownloadsFilterBase + extends $Notifier { HistoryFilterOptions build(); @$mustCallSuper @override @@ -101,17 +160,88 @@ final class BrowsingHistoryProvider } } -String _$browsingHistoryHash() => r'3b6ee5853387481d7544bc9733741a5519f84f40'; +String _$browsingHistoryHash() => r'6f26228da28f6f67844551bf430025b161731bb2'; + +@ProviderFor(browsingDownloads) +final browsingDownloadsProvider = BrowsingDownloadsProvider._(); + +final class BrowsingDownloadsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + BrowsingDownloadsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'browsingDownloadsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$browsingDownloadsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return browsingDownloads(ref); + } +} + +String _$browsingDownloadsHash() => r'd05e3c079c6de349ff9e910ca597ad7c78f87a16'; // ************************************************************************** // JsonGenerator // ************************************************************************** // GENERATED CODE - DO NOT MODIFY BY HAND -abstract class _$HistoryFilter extends _$HistoryFilterBase { +abstract class _$HistoryVisitsFilter extends _$HistoryVisitsFilterBase { /// The default key used by [persist]. String get key { - const resolvedKey = "HistoryFilter"; + const resolvedKey = "HistoryVisitsFilter"; + return resolvedKey; + } + + /// A variant of [persist], for JSON-specific encoding. + /// + /// You can override [key] to customize the key used for storage. + PersistResult persist( + FutureOr> storage, { + String? key, + String Function(HistoryFilterOptions state)? encode, + HistoryFilterOptions Function(String encoded)? decode, + StorageOptions options = const StorageOptions(), + }) { + return NotifierPersistX(this).persist( + storage, + key: key ?? this.key, + encode: encode ?? $jsonCodex.encode, + decode: + decode ?? + (encoded) { + final e = $jsonCodex.decode(encoded); + return HistoryFilterOptions.fromJson(e as Map); + }, + options: options, + ); + } +} + +abstract class _$HistoryDownloadsFilter extends _$HistoryDownloadsFilterBase { + /// The default key used by [persist]. + String get key { + const resolvedKey = "HistoryDownloadsFilter"; return resolvedKey; } diff --git a/app/lib/features/geckoview/features/history/presentation/screens/history.dart b/app/lib/features/geckoview/features/history/presentation/screens/history.dart index 5af6d8c6..36547c74 100644 --- a/app/lib/features/geckoview/features/history/presentation/screens/history.dart +++ b/app/lib/features/geckoview/features/history/presentation/screens/history.dart @@ -57,6 +57,7 @@ class Section extends MultiSliver { required Set selectedItems, required void Function(VisitInfo) onTap, required void Function(VisitInfo) onLongPress, + required Future Function(VisitInfo) onDelete, }) : super( pushPinnedChildren: true, children: [ @@ -103,37 +104,11 @@ class Section extends MultiSliver { ), ), subtitle: UriBreadcrumb(uri: uri), - trailing: Consumer( - builder: (context, ref, _) { - return IconButton( - onPressed: () async { - await ref - .read(historyRepositoryProvider.notifier) - .deleteVisit(item); - - final downloadedFile = item.title.mapNotNull( - (title) => File(title), - ); - - if (await downloadedFile?.exists() == true) { - if (context.mounted) { - final delete = await showDeleteFileDialog( - context, - downloadedFile.toString(), - ); - - if (delete?.delete == true) { - await downloadedFile!.delete(); - } - } - } - - // ignore: unused_result - await ref.refresh(browsingHistoryProvider.future); - }, - icon: const Icon(MdiIcons.closeCircle), - ); + trailing: IconButton( + onPressed: () async { + await onDelete(item); }, + icon: const Icon(MdiIcons.closeCircle), ), onTap: () { onTap(item); @@ -196,21 +171,74 @@ class Section extends MultiSliver { ); } +enum HistoryScreenMode { history, downloads } + class HistoryScreen extends HookConsumerWidget { - const HistoryScreen({super.key}); + const HistoryScreen({super.key, this.mode = HistoryScreenMode.history}); + + final HistoryScreenMode mode; @override Widget build(BuildContext context, WidgetRef ref) { - final historyFilter = ref.watch(historyFilterProvider); + final isDownloadsMode = mode == HistoryScreenMode.downloads; final textFilterEnabled = useState(false); final textFilterController = useTextEditingController(); final menuController = useMenuController(); - final historyEntries = ref.watch(browsingHistoryProvider); + final historyFilter = isDownloadsMode + ? ref.watch(historyDownloadsFilterProvider) + : ref.watch(historyVisitsFilterProvider); + final historyEntries = isDownloadsMode + ? ref.watch(browsingDownloadsProvider) + : ref.watch(browsingHistoryProvider); final selectedItems = useState({}); + final defaultDownloadsFilter = HistoryFilterOptions( + dateRange: null, + visitTypes: const {VisitType.download}, + ); + final hasActiveFilter = isDownloadsMode + ? historyFilter != defaultDownloadsFilter + : historyFilter != HistoryFilterOptions.withDefaults(); + + Future refreshHistoryEntries() async { + if (isDownloadsMode) { + // ignore: unused_result + await ref.refresh(browsingDownloadsProvider.future); + } else { + // ignore: unused_result + await ref.refresh(browsingHistoryProvider.future); + } + } + + void setDateRange(DateTimeRange? range) { + if (isDownloadsMode) { + ref.read(historyDownloadsFilterProvider.notifier).setDateRange(range); + } else { + ref.read(historyVisitsFilterProvider.notifier).setDateRange(range); + } + } + + Future deleteHistoryItem(VisitInfo item) async { + await ref.read(historyRepositoryProvider.notifier).deleteVisit(item); + + final downloadedFile = item.title.mapNotNull((title) => File(title)); + + if (await downloadedFile?.exists() == true && context.mounted) { + final delete = await showDeleteFileDialog( + context, + downloadedFile.toString(), + ); + + if (delete?.delete == true) { + await downloadedFile!.delete(); + } + } + + await refreshHistoryEntries(); + } return Scaffold( appBar: AppBar( @@ -220,7 +248,9 @@ class HistoryScreen extends HookConsumerWidget { decoration: InputDecoration( contentPadding: const EdgeInsets.only(top: 12), border: InputBorder.none, - hintText: 'Filter history...', + hintText: isDownloadsMode + ? 'Filter downloads...' + : 'Filter history...', floatingLabelBehavior: FloatingLabelBehavior.always, suffixIcon: IconButton( onPressed: () { @@ -235,7 +265,7 @@ class HistoryScreen extends HookConsumerWidget { ), ) : selectedItems.value.isEmpty - ? const Text('History') + ? Text(isDownloadsMode ? 'Downloads' : 'History') : Text('${selectedItems.value.length} selected'), actions: [ if (selectedItems.value.isNotEmpty) @@ -272,8 +302,7 @@ class HistoryScreen extends HookConsumerWidget { } selectedItems.value = {}; - // ignore: unused_result - await ref.refresh(browsingHistoryProvider.future); + await refreshHistoryEntries(); }, icon: const Icon(Icons.delete), ) @@ -282,11 +311,15 @@ class HistoryScreen extends HookConsumerWidget { onPressed: () async { await showDeleteDataDialog( context, - initialSettings: {DeleteBrowsingDataType.history}, + initialSettings: { + if (isDownloadsMode) + DeleteBrowsingDataType.downloads + else + DeleteBrowsingDataType.history, + }, ); - // ignore: unused_result - await ref.refresh(browsingHistoryProvider.future); + await refreshHistoryEntries(); }, icon: const Icon(Icons.delete), ), @@ -307,9 +340,7 @@ class HistoryScreen extends HookConsumerWidget { trailingIcon: historyFilter.dateRange.mapNotNull( (_) => IconButton( onPressed: () { - ref - .read(historyFilterProvider.notifier) - .setDateRange(null); + setDateRange(null); }, icon: const Icon(Icons.clear), ), @@ -331,53 +362,52 @@ class HistoryScreen extends HookConsumerWidget { lastDate: DateTime.now(), ); - ref - .read(historyFilterProvider.notifier) - .setDateRange( - range.mapNotNull( - (range) => DateTimeRange( - start: range.start, - //Make sure to include last day fully - end: range.end.add( - const Duration(days: 1) - - const Duration(milliseconds: 1), - ), - ), + setDateRange( + range.mapNotNull( + (range) => DateTimeRange( + start: range.start, + // Make sure to include last day fully. + end: range.end.add( + const Duration(days: 1) - + const Duration(milliseconds: 1), ), - ); + ), + ), + ); }, ), - const Divider(), - ...{VisitType.link, VisitType.reload, VisitType.download}.map( - (type) => CheckboxMenuButton( - closeOnActivate: false, - value: historyFilter.visitTypes.contains(type), - onChanged: (value) { - if (value != null) { - ref - .read(historyFilterProvider.notifier) - .updateVisitType(type, value); - } - }, - child: switch (type) { - VisitType.link => const Text('Followed Links'), - VisitType.typed => const Text('Typed Addresses'), - VisitType.embed => const Text('Embedded Page Elements'), - VisitType.redirectPermanent => const Text( - 'Temporary Redirects', - ), - VisitType.redirectTemporary => const Text( - 'Permanent Redirects', - ), - VisitType.download => const Text('Downloads'), - VisitType.framedLink => const Text('Frames'), - VisitType.reload => const Text('Page Reloads'), - VisitType.bookmark => throw UnimplementedError( - 'VisitType.bookmark filter not implemented', - ), - }, + if (!isDownloadsMode) const Divider(), + if (!isDownloadsMode) + ...{VisitType.link, VisitType.reload, VisitType.download}.map( + (type) => CheckboxMenuButton( + closeOnActivate: false, + value: historyFilter.visitTypes.contains(type), + onChanged: (value) { + if (value != null) { + ref + .read(historyVisitsFilterProvider.notifier) + .updateVisitType(type, value); + } + }, + child: switch (type) { + VisitType.link => const Text('Followed Links'), + VisitType.typed => const Text('Typed Addresses'), + VisitType.embed => const Text('Embedded Page Elements'), + VisitType.redirectPermanent => const Text( + 'Temporary Redirects', + ), + VisitType.redirectTemporary => const Text( + 'Permanent Redirects', + ), + VisitType.download => const Text('Downloads'), + VisitType.framedLink => const Text('Frames'), + VisitType.reload => const Text('Page Reloads'), + VisitType.bookmark => throw UnimplementedError( + 'VisitType.bookmark filter not implemented', + ), + }, + ), ), - ), const Divider(), MenuItemButton( leadingIcon: const Icon(MdiIcons.restore), @@ -385,8 +415,11 @@ class HistoryScreen extends HookConsumerWidget { onPressed: () { textFilterController.clear(); textFilterEnabled.value = false; - - ref.read(historyFilterProvider.notifier).reset(); + if (isDownloadsMode) { + ref.read(historyDownloadsFilterProvider.notifier).reset(); + } else { + ref.read(historyVisitsFilterProvider.notifier).reset(); + } }, ), ], @@ -399,8 +432,7 @@ class HistoryScreen extends HookConsumerWidget { } }, icon: Badge( - isLabelVisible: - historyFilter != HistoryFilterOptions.withDefaults(), + isLabelVisible: hasActiveFilter, child: const Icon(MdiIcons.filter), ), ), @@ -413,8 +445,7 @@ class HistoryScreen extends HookConsumerWidget { data: (data) { return RefreshIndicator( onRefresh: () async { - // ignore: unused_result - await ref.refresh(browsingHistoryProvider.future); + await refreshHistoryEntries(); }, child: HookBuilder( builder: (context) { @@ -464,6 +495,7 @@ class HistoryScreen extends HookConsumerWidget { items: value, selectedItems: selectedItems.value, onLongPress: toggleSelected, + onDelete: deleteHistoryItem, onTap: (item) async { if (selectedItems.value.isNotEmpty) { toggleSelected(item); @@ -492,7 +524,9 @@ class HistoryScreen extends HookConsumerWidget { }, error: (error, stackTrace) => Center( child: FailureWidget( - title: 'Failed to load History', + title: isDownloadsMode + ? 'Failed to load Downloads' + : 'Failed to load History', exception: error, ), ),