add separate history download screen

This commit is contained in:
Fabian Freund
2026-02-21 10:59:56 +01:00
parent 7c14e7b3a3
commit f8d497f991
6 changed files with 372 additions and 115 deletions
+28
View File
@@ -1143,6 +1143,13 @@ RouteBase get $historyRoute => GoRouteData.$route(
path: '/history', path: '/history',
name: 'HistoryRoute', name: 'HistoryRoute',
factory: $HistoryRoute._fromState, factory: $HistoryRoute._fromState,
routes: [
GoRouteData.$route(
path: 'downloads',
name: 'HistoryDownloadsRoute',
factory: $HistoryDownloadsRoute._fromState,
),
],
); );
mixin $HistoryRoute on GoRouteData { mixin $HistoryRoute on GoRouteData {
@@ -1165,6 +1172,27 @@ mixin $HistoryRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location); 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<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);
}
RouteBase get $profileListRoute => GoRouteData.$route( RouteBase get $profileListRoute => GoRouteData.$route(
path: '/profiles', path: '/profiles',
name: 'ProfileListRoute', name: 'ProfileListRoute',
+19 -1
View File
@@ -19,7 +19,16 @@
*/ */
part of 'routes.dart'; part of 'routes.dart';
@TypedGoRoute<HistoryRoute>(name: 'HistoryRoute', path: '/history') @TypedGoRoute<HistoryRoute>(
name: 'HistoryRoute',
path: '/history',
routes: [
TypedGoRoute<HistoryDownloadsRoute>(
name: 'HistoryDownloadsRoute',
path: 'downloads',
),
],
)
class HistoryRoute extends GoRouteData with $HistoryRoute { class HistoryRoute extends GoRouteData with $HistoryRoute {
const HistoryRoute(); const HistoryRoute();
@@ -28,3 +37,12 @@ class HistoryRoute extends GoRouteData with $HistoryRoute {
return const HistoryScreen(); return const HistoryScreen();
} }
} }
class HistoryDownloadsRoute extends GoRouteData with $HistoryDownloadsRoute {
const HistoryDownloadsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HistoryScreen(mode: HistoryScreenMode.downloads);
}
}
@@ -54,12 +54,12 @@ class BrowserNavigationDrawer extends HookConsumerWidget {
return NavigationDrawer( return NavigationDrawer(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [_ProfileHeader(), _SyncTile(), const Divider()],
),
children: [ children: [
// Profile Header
_ProfileHeader(),
_SyncTile(),
const Divider(),
// Section 1: Tools & Configuration // Section 1: Tools & Configuration
_ExtensionsSection(), _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( ListTile(
leading: const Icon(MdiIcons.bookmarkMultiple), leading: const Icon(MdiIcons.bookmarkMultiple),
title: const Text('Bookmarks'), title: const Text('Bookmarks'),
@@ -30,7 +30,7 @@ part 'providers.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
@JsonPersist() @JsonPersist()
class HistoryFilter extends _$HistoryFilter { class HistoryVisitsFilter extends _$HistoryVisitsFilter {
void updateVisitType(VisitType type, bool value) { void updateVisitType(VisitType type, bool value) {
if (value) { if (value) {
state = state.copyWith.visitTypes({...state.visitTypes, type}); state = state.copyWith.visitTypes({...state.visitTypes, type});
@@ -51,16 +51,54 @@ class HistoryFilter extends _$HistoryFilter {
HistoryFilterOptions build() { HistoryFilterOptions build() {
persist( persist(
ref.watch(riverpodDatabaseStorageProvider), ref.watch(riverpodDatabaseStorageProvider),
key: 'HistoryFilterOptions', key: 'HistoryVisitsFilterOptions',
); );
return stateOrNull ?? HistoryFilterOptions.withDefaults(); 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<DateTime>? 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() @Riverpod()
Future<List<VisitInfo>> browsingHistory(Ref ref) { Future<List<VisitInfo>> browsingHistory(Ref ref) {
final options = ref.watch(historyFilterProvider); final options = ref.watch(historyVisitsFilterProvider);
return ref
.read(historyRepositoryProvider.notifier)
.getDetailedVisits(options);
}
@Riverpod()
Future<List<VisitInfo>> browsingDownloads(Ref ref) {
final options = ref.watch(historyDownloadsFilterProvider);
return ref return ref
.read(historyRepositoryProvider.notifier) .read(historyRepositoryProvider.notifier)
@@ -9,30 +9,30 @@ part of 'providers.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
@ProviderFor(HistoryFilter) @ProviderFor(HistoryVisitsFilter)
@JsonPersist() @JsonPersist()
final historyFilterProvider = HistoryFilterProvider._(); final historyVisitsFilterProvider = HistoryVisitsFilterProvider._();
@JsonPersist() @JsonPersist()
final class HistoryFilterProvider final class HistoryVisitsFilterProvider
extends $NotifierProvider<HistoryFilter, HistoryFilterOptions> { extends $NotifierProvider<HistoryVisitsFilter, HistoryFilterOptions> {
HistoryFilterProvider._() HistoryVisitsFilterProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'historyFilterProvider', name: r'historyVisitsFilterProvider',
isAutoDispose: false, isAutoDispose: false,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$historyFilterHash(); String debugGetCreateSourceHash() => _$historyVisitsFilterHash();
@$internal @$internal
@override @override
HistoryFilter create() => HistoryFilter(); HistoryVisitsFilter create() => HistoryVisitsFilter();
/// {@macro riverpod.override_with_value} /// {@macro riverpod.override_with_value}
Override overrideWithValue(HistoryFilterOptions value) { Override overrideWithValue(HistoryFilterOptions value) {
@@ -43,10 +43,69 @@ final class HistoryFilterProvider
} }
} }
String _$historyFilterHash() => r'685b92590011ec450ef0c19820e4a6750d26b79d'; String _$historyVisitsFilterHash() =>
r'a4bf6c41c9180166365084cb7c7981558ac7ad36';
@JsonPersist() @JsonPersist()
abstract class _$HistoryFilterBase extends $Notifier<HistoryFilterOptions> { abstract class _$HistoryVisitsFilterBase
extends $Notifier<HistoryFilterOptions> {
HistoryFilterOptions build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<HistoryFilterOptions, HistoryFilterOptions>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<HistoryFilterOptions, HistoryFilterOptions>,
HistoryFilterOptions,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(HistoryDownloadsFilter)
@JsonPersist()
final historyDownloadsFilterProvider = HistoryDownloadsFilterProvider._();
@JsonPersist()
final class HistoryDownloadsFilterProvider
extends $NotifierProvider<HistoryDownloadsFilter, HistoryFilterOptions> {
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<HistoryFilterOptions>(value),
);
}
}
String _$historyDownloadsFilterHash() =>
r'b44de5aced7dadba361fddf69b8462ff92252a99';
@JsonPersist()
abstract class _$HistoryDownloadsFilterBase
extends $Notifier<HistoryFilterOptions> {
HistoryFilterOptions build(); HistoryFilterOptions build();
@$mustCallSuper @$mustCallSuper
@override @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<VisitInfo>>,
List<VisitInfo>,
FutureOr<List<VisitInfo>>
>
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> {
BrowsingDownloadsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browsingDownloadsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browsingDownloadsHash();
@$internal
@override
$FutureProviderElement<List<VisitInfo>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<VisitInfo>> create(Ref ref) {
return browsingDownloads(ref);
}
}
String _$browsingDownloadsHash() => r'd05e3c079c6de349ff9e910ca597ad7c78f87a16';
// ************************************************************************** // **************************************************************************
// JsonGenerator // JsonGenerator
// ************************************************************************** // **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
abstract class _$HistoryFilter extends _$HistoryFilterBase { abstract class _$HistoryVisitsFilter extends _$HistoryVisitsFilterBase {
/// The default key used by [persist]. /// The default key used by [persist].
String get key { 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, String>> storage, {
String? key,
String Function(HistoryFilterOptions state)? encode,
HistoryFilterOptions Function(String encoded)? decode,
StorageOptions options = const StorageOptions(),
}) {
return NotifierPersistX(this).persist<String, String>(
storage,
key: key ?? this.key,
encode: encode ?? $jsonCodex.encode,
decode:
decode ??
(encoded) {
final e = $jsonCodex.decode(encoded);
return HistoryFilterOptions.fromJson(e as Map<String, Object?>);
},
options: options,
);
}
}
abstract class _$HistoryDownloadsFilter extends _$HistoryDownloadsFilterBase {
/// The default key used by [persist].
String get key {
const resolvedKey = "HistoryDownloadsFilter";
return resolvedKey; return resolvedKey;
} }
@@ -57,6 +57,7 @@ class Section extends MultiSliver {
required Set<VisitInfo> selectedItems, required Set<VisitInfo> selectedItems,
required void Function(VisitInfo) onTap, required void Function(VisitInfo) onTap,
required void Function(VisitInfo) onLongPress, required void Function(VisitInfo) onLongPress,
required Future<void> Function(VisitInfo) onDelete,
}) : super( }) : super(
pushPinnedChildren: true, pushPinnedChildren: true,
children: [ children: [
@@ -103,37 +104,11 @@ class Section extends MultiSliver {
), ),
), ),
subtitle: UriBreadcrumb(uri: uri), subtitle: UriBreadcrumb(uri: uri),
trailing: Consumer( trailing: IconButton(
builder: (context, ref, _) { onPressed: () async {
return IconButton( await onDelete(item);
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),
);
}, },
icon: const Icon(MdiIcons.closeCircle),
), ),
onTap: () { onTap: () {
onTap(item); onTap(item);
@@ -196,21 +171,74 @@ class Section extends MultiSliver {
); );
} }
enum HistoryScreenMode { history, downloads }
class HistoryScreen extends HookConsumerWidget { class HistoryScreen extends HookConsumerWidget {
const HistoryScreen({super.key}); const HistoryScreen({super.key, this.mode = HistoryScreenMode.history});
final HistoryScreenMode mode;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final historyFilter = ref.watch(historyFilterProvider); final isDownloadsMode = mode == HistoryScreenMode.downloads;
final textFilterEnabled = useState(false); final textFilterEnabled = useState(false);
final textFilterController = useTextEditingController(); final textFilterController = useTextEditingController();
final menuController = useMenuController(); 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(<VisitInfo>{}); final selectedItems = useState(<VisitInfo>{});
final defaultDownloadsFilter = HistoryFilterOptions(
dateRange: null,
visitTypes: const {VisitType.download},
);
final hasActiveFilter = isDownloadsMode
? historyFilter != defaultDownloadsFilter
: historyFilter != HistoryFilterOptions.withDefaults();
Future<void> refreshHistoryEntries() async {
if (isDownloadsMode) {
// ignore: unused_result
await ref.refresh(browsingDownloadsProvider.future);
} else {
// ignore: unused_result
await ref.refresh(browsingHistoryProvider.future);
}
}
void setDateRange(DateTimeRange<DateTime>? range) {
if (isDownloadsMode) {
ref.read(historyDownloadsFilterProvider.notifier).setDateRange(range);
} else {
ref.read(historyVisitsFilterProvider.notifier).setDateRange(range);
}
}
Future<void> 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -220,7 +248,9 @@ class HistoryScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.only(top: 12), contentPadding: const EdgeInsets.only(top: 12),
border: InputBorder.none, border: InputBorder.none,
hintText: 'Filter history...', hintText: isDownloadsMode
? 'Filter downloads...'
: 'Filter history...',
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: IconButton( suffixIcon: IconButton(
onPressed: () { onPressed: () {
@@ -235,7 +265,7 @@ class HistoryScreen extends HookConsumerWidget {
), ),
) )
: selectedItems.value.isEmpty : selectedItems.value.isEmpty
? const Text('History') ? Text(isDownloadsMode ? 'Downloads' : 'History')
: Text('${selectedItems.value.length} selected'), : Text('${selectedItems.value.length} selected'),
actions: [ actions: [
if (selectedItems.value.isNotEmpty) if (selectedItems.value.isNotEmpty)
@@ -272,8 +302,7 @@ class HistoryScreen extends HookConsumerWidget {
} }
selectedItems.value = {}; selectedItems.value = {};
// ignore: unused_result await refreshHistoryEntries();
await ref.refresh(browsingHistoryProvider.future);
}, },
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
) )
@@ -282,11 +311,15 @@ class HistoryScreen extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
await showDeleteDataDialog( await showDeleteDataDialog(
context, context,
initialSettings: {DeleteBrowsingDataType.history}, initialSettings: {
if (isDownloadsMode)
DeleteBrowsingDataType.downloads
else
DeleteBrowsingDataType.history,
},
); );
// ignore: unused_result await refreshHistoryEntries();
await ref.refresh(browsingHistoryProvider.future);
}, },
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
), ),
@@ -307,9 +340,7 @@ class HistoryScreen extends HookConsumerWidget {
trailingIcon: historyFilter.dateRange.mapNotNull( trailingIcon: historyFilter.dateRange.mapNotNull(
(_) => IconButton( (_) => IconButton(
onPressed: () { onPressed: () {
ref setDateRange(null);
.read(historyFilterProvider.notifier)
.setDateRange(null);
}, },
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
), ),
@@ -331,53 +362,52 @@ class HistoryScreen extends HookConsumerWidget {
lastDate: DateTime.now(), lastDate: DateTime.now(),
); );
ref setDateRange(
.read(historyFilterProvider.notifier) range.mapNotNull(
.setDateRange( (range) => DateTimeRange(
range.mapNotNull( start: range.start,
(range) => DateTimeRange( // Make sure to include last day fully.
start: range.start, end: range.end.add(
//Make sure to include last day fully const Duration(days: 1) -
end: range.end.add( const Duration(milliseconds: 1),
const Duration(days: 1) -
const Duration(milliseconds: 1),
),
),
), ),
); ),
),
);
}, },
), ),
const Divider(), if (!isDownloadsMode) const Divider(),
...{VisitType.link, VisitType.reload, VisitType.download}.map( if (!isDownloadsMode)
(type) => CheckboxMenuButton( ...{VisitType.link, VisitType.reload, VisitType.download}.map(
closeOnActivate: false, (type) => CheckboxMenuButton(
value: historyFilter.visitTypes.contains(type), closeOnActivate: false,
onChanged: (value) { value: historyFilter.visitTypes.contains(type),
if (value != null) { onChanged: (value) {
ref if (value != null) {
.read(historyFilterProvider.notifier) ref
.updateVisitType(type, value); .read(historyVisitsFilterProvider.notifier)
} .updateVisitType(type, value);
}, }
child: switch (type) { },
VisitType.link => const Text('Followed Links'), child: switch (type) {
VisitType.typed => const Text('Typed Addresses'), VisitType.link => const Text('Followed Links'),
VisitType.embed => const Text('Embedded Page Elements'), VisitType.typed => const Text('Typed Addresses'),
VisitType.redirectPermanent => const Text( VisitType.embed => const Text('Embedded Page Elements'),
'Temporary Redirects', VisitType.redirectPermanent => const Text(
), 'Temporary Redirects',
VisitType.redirectTemporary => const Text( ),
'Permanent Redirects', VisitType.redirectTemporary => const Text(
), 'Permanent Redirects',
VisitType.download => const Text('Downloads'), ),
VisitType.framedLink => const Text('Frames'), VisitType.download => const Text('Downloads'),
VisitType.reload => const Text('Page Reloads'), VisitType.framedLink => const Text('Frames'),
VisitType.bookmark => throw UnimplementedError( VisitType.reload => const Text('Page Reloads'),
'VisitType.bookmark filter not implemented', VisitType.bookmark => throw UnimplementedError(
), 'VisitType.bookmark filter not implemented',
}, ),
},
),
), ),
),
const Divider(), const Divider(),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore), leadingIcon: const Icon(MdiIcons.restore),
@@ -385,8 +415,11 @@ class HistoryScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
textFilterController.clear(); textFilterController.clear();
textFilterEnabled.value = false; textFilterEnabled.value = false;
if (isDownloadsMode) {
ref.read(historyFilterProvider.notifier).reset(); ref.read(historyDownloadsFilterProvider.notifier).reset();
} else {
ref.read(historyVisitsFilterProvider.notifier).reset();
}
}, },
), ),
], ],
@@ -399,8 +432,7 @@ class HistoryScreen extends HookConsumerWidget {
} }
}, },
icon: Badge( icon: Badge(
isLabelVisible: isLabelVisible: hasActiveFilter,
historyFilter != HistoryFilterOptions.withDefaults(),
child: const Icon(MdiIcons.filter), child: const Icon(MdiIcons.filter),
), ),
), ),
@@ -413,8 +445,7 @@ class HistoryScreen extends HookConsumerWidget {
data: (data) { data: (data) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
// ignore: unused_result await refreshHistoryEntries();
await ref.refresh(browsingHistoryProvider.future);
}, },
child: HookBuilder( child: HookBuilder(
builder: (context) { builder: (context) {
@@ -464,6 +495,7 @@ class HistoryScreen extends HookConsumerWidget {
items: value, items: value,
selectedItems: selectedItems.value, selectedItems: selectedItems.value,
onLongPress: toggleSelected, onLongPress: toggleSelected,
onDelete: deleteHistoryItem,
onTap: (item) async { onTap: (item) async {
if (selectedItems.value.isNotEmpty) { if (selectedItems.value.isNotEmpty) {
toggleSelected(item); toggleSelected(item);
@@ -492,7 +524,9 @@ class HistoryScreen extends HookConsumerWidget {
}, },
error: (error, stackTrace) => Center( error: (error, stackTrace) => Center(
child: FailureWidget( child: FailureWidget(
title: 'Failed to load History', title: isDownloadsMode
? 'Failed to load Downloads'
: 'Failed to load History',
exception: error, exception: error,
), ),
), ),