prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/data/database/converters/date_time_range.dart';
part 'history_filter_options.g.dart';
@JsonSerializable()
@CopyWith()
class HistoryFilterOptions with FastEquatable {
@DateTimeRangeConverter()
final DateTimeRange<DateTime>? dateRange;
final Set<VisitType> visitTypes;
HistoryFilterOptions({required this.dateRange, required this.visitTypes});
HistoryFilterOptions.withDefaults()
: this(dateRange: null, visitTypes: {VisitType.link});
@override
List<Object?> get hashParameters => [dateRange, visitTypes];
factory HistoryFilterOptions.fromJson(Map<String, dynamic> json) =>
_$HistoryFilterOptionsFromJson(json);
Map<String, dynamic> toJson() => _$HistoryFilterOptionsToJson(this);
}
@@ -0,0 +1,109 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'history_filter_options.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$HistoryFilterOptionsCWProxy {
HistoryFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// HistoryFilterOptions(...).copyWith(id: 12, name: "My name")
/// ```
HistoryFilterOptions call({
DateTimeRange<DateTime>? dateRange,
Set<VisitType> visitTypes,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfHistoryFilterOptions.copyWith(...)` or call `instanceOfHistoryFilterOptions.copyWith.fieldName(value)` for a single field.
class _$HistoryFilterOptionsCWProxyImpl
implements _$HistoryFilterOptionsCWProxy {
const _$HistoryFilterOptionsCWProxyImpl(this._value);
final HistoryFilterOptions _value;
@override
HistoryFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
call(dateRange: dateRange);
@override
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes) =>
call(visitTypes: visitTypes);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// HistoryFilterOptions(...).copyWith(id: 12, name: "My name")
/// ```
HistoryFilterOptions call({
Object? dateRange = const $CopyWithPlaceholder(),
Object? visitTypes = const $CopyWithPlaceholder(),
}) {
return HistoryFilterOptions(
dateRange: dateRange == const $CopyWithPlaceholder()
? _value.dateRange
// ignore: cast_nullable_to_non_nullable
: dateRange as DateTimeRange<DateTime>?,
visitTypes:
visitTypes == const $CopyWithPlaceholder() || visitTypes == null
? _value.visitTypes
// ignore: cast_nullable_to_non_nullable
: visitTypes as Set<VisitType>,
);
}
}
extension $HistoryFilterOptionsCopyWith on HistoryFilterOptions {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfHistoryFilterOptions.copyWith(...)` or `instanceOfHistoryFilterOptions.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$HistoryFilterOptionsCWProxy get copyWith =>
_$HistoryFilterOptionsCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
HistoryFilterOptions _$HistoryFilterOptionsFromJson(
Map<String, dynamic> json,
) => HistoryFilterOptions(
dateRange: const DateTimeRangeConverter().fromJson(
json['dateRange'] as Map<String, dynamic>?,
),
visitTypes: (json['visitTypes'] as List<dynamic>)
.map((e) => $enumDecode(_$VisitTypeEnumMap, e))
.toSet(),
);
Map<String, dynamic> _$HistoryFilterOptionsToJson(
HistoryFilterOptions instance,
) => <String, dynamic>{
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
'visitTypes': instance.visitTypes.map((e) => _$VisitTypeEnumMap[e]!).toList(),
};
const _$VisitTypeEnumMap = {
VisitType.link: 'link',
VisitType.typed: 'typed',
VisitType.bookmark: 'bookmark',
VisitType.embed: 'embed',
VisitType.redirectPermanent: 'redirectPermanent',
VisitType.redirectTemporary: 'redirectTemporary',
VisitType.download: 'download',
VisitType.framedLink: 'framedLink',
VisitType.reload: 'reload',
};
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/json_persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/user/data/providers.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
@JsonPersist()
class HistoryVisitsFilter extends _$HistoryVisitsFilter {
void updateVisitType(VisitType type, bool value) {
if (value) {
state = state.copyWith.visitTypes({...state.visitTypes, type});
} else {
state = state.copyWith.visitTypes({...state.visitTypes}..remove(type));
}
}
void reset() {
state = HistoryFilterOptions.withDefaults();
}
void setDateRange(DateTimeRange<DateTime>? range) {
state = state.copyWith.dateRange(range);
}
@override
HistoryFilterOptions build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
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<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()
Future<List<VisitInfo>> browsingHistory(Ref ref) {
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
.read(historyRepositoryProvider.notifier)
.getDetailedVisits(options);
}
@@ -0,0 +1,271 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(HistoryVisitsFilter)
@JsonPersist()
final historyVisitsFilterProvider = HistoryVisitsFilterProvider._();
@JsonPersist()
final class HistoryVisitsFilterProvider
extends $NotifierProvider<HistoryVisitsFilter, HistoryFilterOptions> {
HistoryVisitsFilterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'historyVisitsFilterProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$historyVisitsFilterHash();
@$internal
@override
HistoryVisitsFilter create() => HistoryVisitsFilter();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(HistoryFilterOptions value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<HistoryFilterOptions>(value),
);
}
}
String _$historyVisitsFilterHash() =>
r'a4bf6c41c9180166365084cb7c7981558ac7ad36';
@JsonPersist()
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();
@$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(browsingHistory)
final browsingHistoryProvider = BrowsingHistoryProvider._();
final class BrowsingHistoryProvider
extends
$FunctionalProvider<
AsyncValue<List<VisitInfo>>,
List<VisitInfo>,
FutureOr<List<VisitInfo>>
>
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> {
BrowsingHistoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browsingHistoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browsingHistoryHash();
@$internal
@override
$FutureProviderElement<List<VisitInfo>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<VisitInfo>> create(Ref ref) {
return browsingHistory(ref);
}
}
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
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
abstract class _$HistoryVisitsFilter extends _$HistoryVisitsFilterBase {
/// The default key used by [persist].
String get key {
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;
}
/// 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,
);
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
part 'history.g.dart';
@Riverpod(keepAlive: true)
class HistoryRepository extends _$HistoryRepository {
final _service = GeckoHistoryService();
Future<void> deleteVisitsBetween(DateTime start, DateTime end) {
return _service.deleteVisitsBetween(start, end);
}
Future<List<VisitInfo>> getDetailedVisits(HistoryFilterOptions options) {
return _service
.getDetailedVisits(
options.dateRange?.start ?? DateTime(0),
options.dateRange?.end ?? DateTime(9999),
options.visitTypes,
)
.then(
(visits) =>
visits..sort((a, b) => b.visitTime.compareTo(a.visitTime)),
);
}
Future<List<VisitInfo>> getVisitsPaginated({
required int count,
int offset = 0,
Set<VisitType> types = const {VisitType.link},
}) {
return _service.getVisitsPaginated(offset, count, types);
}
Future<void> deleteVisit(VisitInfo info) {
return _service.deleteVisit(info);
}
Future<List<HistoryHighlight>> getHistoryHighlights({
double viewTimeWeight = 10.0,
double frequencyWeight = 4.0,
required int limit,
}) {
return _service.getHistoryHighlights(
weights: HistoryHighlightWeights(
viewTime: viewTimeWeight,
frequency: frequencyWeight,
),
limit: limit,
);
}
Future<List<TopFrecentSiteInfo>> getTopFrecentSites({
required int limit,
FrecencyThresholdOption frecencyThreshold =
FrecencyThresholdOption.skipOneTimePages,
}) {
return _service.getTopFrecentSites(
limit: limit,
frecencyThreshold: frecencyThreshold,
);
}
@override
void build() {}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'history.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(HistoryRepository)
final historyRepositoryProvider = HistoryRepositoryProvider._();
final class HistoryRepositoryProvider
extends $NotifierProvider<HistoryRepository, void> {
HistoryRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'historyRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$historyRepositoryHash();
@$internal
@override
HistoryRepository create() => HistoryRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$historyRepositoryHash() => r'414b68ec6be3cc2eca3681cbc00990e53e6127ce';
abstract class _$HistoryRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:path/path.dart' as p;
typedef DeleteDecision = ({bool delete, bool remember});
Future<DeleteDecision?> showDeleteFileDialog(
BuildContext context,
String filePath, {
bool multiFileMode = false,
}) {
return showDialog<DeleteDecision>(
context: context,
builder: (context) {
final fileName = p.basename(filePath);
return HookBuilder(
builder: (context) {
final remember = useState(false);
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Downloaded File?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
children: [
const TextSpan(text: 'Would you like to delete '),
TextSpan(
text: fileName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const TextSpan(text: ' from your device?'),
],
),
),
const SizedBox(height: 8),
Text(
'This action cannot be undone.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (multiFileMode) ...[
const SizedBox(height: 16),
CheckboxListTile.adaptive(
value: remember.value,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
remember.value = value!;
},
title: const Text('Remember my choice for remaining files'),
),
],
],
),
actions: [
TextButton(
onPressed: () {
context.pop<DeleteDecision>((
delete: false,
remember: remember.value,
));
},
child: const Text('Keep File'),
),
FilledButton(
onPressed: () {
context.pop<DeleteDecision>((
delete: true,
remember: remember.value,
));
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Delete'),
),
],
);
},
);
},
);
}
@@ -0,0 +1,538 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:nullability/nullability.dart';
import 'package:path/path.dart' as p;
import 'package:sliver_tools/sliver_tools.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class Section extends MultiSliver {
static final _datePattern = DateFormat.MMMd().addPattern('Hm');
Section({
super.key,
required BuildContext context,
required String title,
required List<VisitInfo> items,
required Set<VisitInfo> selectedItems,
required void Function(VisitInfo) onTap,
required void Function(VisitInfo) onLongPress,
required Future<void> Function(VisitInfo) onDelete,
}) : super(
pushPinnedChildren: true,
children: [
SliverPinnedHeader(
child: Container(
padding: const EdgeInsets.only(left: 24, top: 8),
color: Theme.of(context).canvasColor,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
const Divider(),
],
),
),
),
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final uri = Uri.parse(item.url);
return Column(
key: ValueKey(item.hashCode),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: selectedItems.contains(item)
? const CircleAvatar(
radius: 12,
child: Icon(Icons.check, size: 12),
)
: UrlIcon([uri], iconSize: 24),
title: item.title.mapNotNull(
(title) => Text(
switch (item.visitType) {
VisitType.download => p.basename(title),
_ => title,
},
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
subtitle: UriBreadcrumb(uri: uri),
trailing: IconButton(
onPressed: () async {
await onDelete(item);
},
icon: const Icon(MdiIcons.closeCircle),
),
onTap: () {
onTap(item);
},
onLongPress: () {
onLongPress(item);
},
),
Padding(
padding: const EdgeInsets.only(left: 54, right: 16),
child: Wrap(
spacing: 8.0,
children: [
Chip(
avatar: switch (item.visitType) {
VisitType.link => const Icon(MdiIcons.openInNew),
VisitType.download => const Icon(
MdiIcons.fileDownload,
),
VisitType.reload => const Icon(MdiIcons.reload),
_ => null,
},
label: switch (item.visitType) {
VisitType.link => const Text('Followed Link'),
VisitType.typed => const Text('Typed Address'),
VisitType.embed => const Text(
'Embedded Page Element',
),
VisitType.redirectPermanent => const Text(
'Temporary Redirect',
),
VisitType.redirectTemporary => const Text(
'Permanent Redirect',
),
VisitType.download => const Text('Download'),
VisitType.framedLink => const Text('Frame'),
VisitType.reload => const Text('Page Reload'),
VisitType.bookmark => throw UnimplementedError(
'VisitType.bookmark chip display not implemented',
),
},
),
Chip(
label: Text(
_datePattern.format(
DateTime.fromMillisecondsSinceEpoch(
item.visitTime,
),
),
),
),
],
),
),
],
);
},
),
],
);
}
enum HistoryScreenMode { history, downloads }
class HistoryScreen extends HookConsumerWidget {
const HistoryScreen({super.key, this.mode = HistoryScreenMode.history});
final HistoryScreenMode mode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDownloadsMode = mode == HistoryScreenMode.downloads;
final textFilterEnabled = useState(false);
final textFilterController = useTextEditingController();
final menuController = useMenuController();
final historyFilter = isDownloadsMode
? ref.watch(historyDownloadsFilterProvider)
: ref.watch(historyVisitsFilterProvider);
final historyEntries = isDownloadsMode
? ref.watch(browsingDownloadsProvider)
: ref.watch(browsingHistoryProvider);
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(
appBar: AppBar(
title: textFilterEnabled.value
? TextField(
controller: textFilterController,
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(top: 12),
border: InputBorder.none,
hintText: isDownloadsMode
? 'Filter downloads...'
: 'Filter history...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: IconButton(
onPressed: () {
if (textFilterController.text.isNotEmpty) {
textFilterController.clear();
} else {
textFilterEnabled.value = false;
}
},
icon: const Icon(Icons.clear),
),
),
)
: selectedItems.value.isEmpty
? Text(isDownloadsMode ? 'Downloads' : 'History')
: Text('${selectedItems.value.length} selected'),
actions: [
if (selectedItems.value.isNotEmpty)
IconButton(
onPressed: () async {
DeleteDecision? deleteDecision;
for (final item in selectedItems.value) {
await ref
.read(historyRepositoryProvider.notifier)
.deleteVisit(item);
final downloadedFile = item.title.mapNotNull(
(title) => File(title),
);
if (await downloadedFile?.exists() == true) {
if (deleteDecision?.remember == true) {
if (deleteDecision?.delete == true) {
await downloadedFile!.delete();
}
} else if (context.mounted) {
deleteDecision = await showDeleteFileDialog(
context,
downloadedFile.toString(),
multiFileMode: true,
);
if (deleteDecision?.delete == true) {
await downloadedFile!.delete();
}
}
}
}
selectedItems.value = {};
await refreshHistoryEntries();
},
icon: const Icon(Icons.delete),
)
else
IconButton(
onPressed: () async {
await showDeleteDataDialog(
context,
initialSettings: {
if (isDownloadsMode)
DeleteBrowsingDataType.downloads
else
DeleteBrowsingDataType.history,
},
);
await refreshHistoryEntries();
},
icon: const Icon(Icons.delete),
),
MenuAnchor(
controller: menuController,
consumeOutsideTap: true,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.search),
child: const Text('Text Filter'),
onPressed: () {
textFilterEnabled.value = true;
},
),
MenuItemButton(
closeOnActivate: false,
leadingIcon: const Icon(MdiIcons.calendarRange),
trailingIcon: historyFilter.dateRange.mapNotNull(
(_) => IconButton(
onPressed: () {
setDateRange(null);
},
icon: const Icon(Icons.clear),
),
),
child:
historyFilter.dateRange.mapNotNull(
(range) => Text(
'${DateFormat.yMd().format(range.start)} - ${DateFormat.yMd().format(range.end)}',
),
) ??
const Text('Filter Date'),
onPressed: () async {
final range = await showDateRangePicker(
context: context,
initialDateRange: historyFilter.dateRange,
firstDate: DateTime.now().subtract(
const Duration(days: 365),
),
lastDate: DateTime.now(),
);
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),
),
),
),
);
},
),
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),
child: const Text('Reset Filter'),
onPressed: () {
textFilterController.clear();
textFilterEnabled.value = false;
if (isDownloadsMode) {
ref.read(historyDownloadsFilterProvider.notifier).reset();
} else {
ref.read(historyVisitsFilterProvider.notifier).reset();
}
},
),
],
child: IconButton(
onPressed: () {
if (menuController.isOpen) {
menuController.close();
} else {
menuController.open();
}
},
icon: Badge(
isLabelVisible: hasActiveFilter,
child: const Icon(MdiIcons.filter),
),
),
),
],
),
body: SafeArea(
child: historyEntries.when(
skipLoadingOnReload: true,
data: (data) {
return RefreshIndicator(
onRefresh: () async {
await refreshHistoryEntries();
},
child: HookBuilder(
builder: (context) {
final textFilter = useListenableSelector(
textFilterController,
() => textFilterController.text.toLowerCase(),
);
final groups = useMemoized(
() => data
.where(
(visit) =>
textFilter.isEmpty ||
visit.title?.toLowerCase().contains(textFilter) ==
true ||
visit.url.toLowerCase().contains(textFilter),
)
.groupListsBy(
(element) => timeago.format(
DateTime.fromMillisecondsSinceEpoch(
element.visitTime,
),
),
),
[EquatableValue(data), textFilter],
);
void toggleSelected(VisitInfo item) {
if (selectedItems.value.contains(item)) {
selectedItems.value = {...selectedItems.value}
..remove(item);
} else {
selectedItems.value = {...selectedItems.value, item};
}
}
return FadingScroll(
startFadingSize: 0.0,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
for (final MapEntry(:key, :value) in groups.entries)
Section(
context: context,
title: key,
items: value,
selectedItems: selectedItems.value,
onLongPress: toggleSelected,
onDelete: deleteHistoryItem,
onTap: (item) async {
if (selectedItems.value.isNotEmpty) {
toggleSelected(item);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse(item.url),
tabMode: TabMode.regular,
selectTab: true,
);
if (context.mounted) {
context.pop();
}
}
},
),
],
);
},
);
},
),
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: isDownloadsMode
? 'Failed to load Downloads'
: 'Failed to load History',
exception: error,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}