improve history screen
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
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(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@@ -8,14 +9,19 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:intl/intl.dart' show DateFormat;
|
import 'package:intl/intl.dart' show DateFormat;
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
|
import 'package:universal_io/io.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
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/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/providers.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.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';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
|
||||||
class Section extends MultiSliver {
|
class Section extends MultiSliver {
|
||||||
@@ -50,6 +56,7 @@ class Section extends MultiSliver {
|
|||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
|
final uri = Uri.parse(item.url);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
key: ValueKey(item.hashCode),
|
key: ValueKey(item.hashCode),
|
||||||
@@ -62,19 +69,42 @@ class Section extends MultiSliver {
|
|||||||
radius: 12,
|
radius: 12,
|
||||||
child: Icon(Icons.check, size: 12),
|
child: Icon(Icons.check, size: 12),
|
||||||
)
|
)
|
||||||
: UrlIcon([Uri.parse(item.url)], iconSize: 24),
|
: UrlIcon([uri], iconSize: 24),
|
||||||
title: item.title.mapNotNull((title) => Text(title)),
|
title: item.title.mapNotNull(
|
||||||
subtitle: Text(
|
(title) => Text(
|
||||||
item.url,
|
switch (item.visitType) {
|
||||||
maxLines: 2,
|
VisitType.download => p.basename(title),
|
||||||
overflow: TextOverflow.ellipsis,
|
_ => title,
|
||||||
|
},
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
subtitle: UriBreadcrumb(uri: uri),
|
||||||
trailing: Consumer(
|
trailing: Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final service = GeckoHistoryService();
|
final service = GeckoHistoryService();
|
||||||
await service.deleteVisit(item);
|
await service.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
|
// ignore: unused_result
|
||||||
await ref.refresh(browsingHistoryProvider.future);
|
await ref.refresh(browsingHistoryProvider.future);
|
||||||
},
|
},
|
||||||
@@ -95,6 +125,14 @@ class Section extends MultiSliver {
|
|||||||
spacing: 8.0,
|
spacing: 8.0,
|
||||||
children: [
|
children: [
|
||||||
Chip(
|
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) {
|
label: switch (item.visitType) {
|
||||||
VisitType.link => const Text('Followed Link'),
|
VisitType.link => const Text('Followed Link'),
|
||||||
VisitType.typed => const Text('Typed Address'),
|
VisitType.typed => const Text('Typed Address'),
|
||||||
@@ -140,6 +178,9 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final historyFilter = ref.watch(historyFilterProvider);
|
final historyFilter = ref.watch(historyFilterProvider);
|
||||||
|
|
||||||
|
final textFilterEnabled = useState(false);
|
||||||
|
final textFilterController = useTextEditingController();
|
||||||
|
|
||||||
final menuController = useMenuController();
|
final menuController = useMenuController();
|
||||||
|
|
||||||
final historyEntries = ref.watch(browsingHistoryProvider);
|
final historyEntries = ref.watch(browsingHistoryProvider);
|
||||||
@@ -148,16 +189,60 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: selectedItems.value.isEmpty
|
title: textFilterEnabled.value
|
||||||
|
? TextField(
|
||||||
|
controller: textFilterController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.only(top: 12),
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: '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
|
||||||
? const Text('History')
|
? const Text('History')
|
||||||
: Text('${selectedItems.value.length} selected'),
|
: Text('${selectedItems.value.length} selected'),
|
||||||
actions: [
|
actions: [
|
||||||
if (selectedItems.value.isNotEmpty)
|
if (selectedItems.value.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
DeleteDecision? deleteDecision;
|
||||||
|
|
||||||
for (final item in selectedItems.value) {
|
for (final item in selectedItems.value) {
|
||||||
final service = GeckoHistoryService();
|
final service = GeckoHistoryService();
|
||||||
await service.deleteVisit(item);
|
await service.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 = {};
|
selectedItems.value = {};
|
||||||
@@ -185,8 +270,17 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
controller: menuController,
|
controller: menuController,
|
||||||
|
consumeOutsideTap: true,
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(Icons.search),
|
||||||
|
child: const Text('Text Filter'),
|
||||||
|
onPressed: () {
|
||||||
|
textFilterEnabled.value = true;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
closeOnActivate: false,
|
||||||
leadingIcon: const Icon(MdiIcons.calendarRange),
|
leadingIcon: const Icon(MdiIcons.calendarRange),
|
||||||
trailingIcon: historyFilter.dateRange.mapNotNull(
|
trailingIcon: historyFilter.dateRange.mapNotNull(
|
||||||
(_) => IconButton(
|
(_) => IconButton(
|
||||||
@@ -232,13 +326,9 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
...{
|
...{VisitType.link, VisitType.reload, VisitType.download}.map(
|
||||||
VisitType.link,
|
|
||||||
VisitType.typed,
|
|
||||||
VisitType.reload,
|
|
||||||
VisitType.download,
|
|
||||||
}.map(
|
|
||||||
(type) => CheckboxMenuButton(
|
(type) => CheckboxMenuButton(
|
||||||
|
closeOnActivate: false,
|
||||||
value: historyFilter.visitTypes.contains(type),
|
value: historyFilter.visitTypes.contains(type),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -264,6 +354,17 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(MdiIcons.restore),
|
||||||
|
child: const Text('Reset Filter'),
|
||||||
|
onPressed: () {
|
||||||
|
textFilterController.clear();
|
||||||
|
textFilterEnabled.value = false;
|
||||||
|
|
||||||
|
ref.read(historyFilterProvider.notifier).reset();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -273,77 +374,104 @@ class HistoryScreen extends HookConsumerWidget {
|
|||||||
menuController.open();
|
menuController.open();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(MdiIcons.filter),
|
icon: Badge(
|
||||||
|
isLabelVisible:
|
||||||
|
historyFilter != HistoryFilterOptions.withDefaults(),
|
||||||
|
child: const Icon(MdiIcons.filter),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: historyEntries.when(
|
body: SafeArea(
|
||||||
data: (data) {
|
child: historyEntries.when(
|
||||||
return RefreshIndicator(
|
data: (data) {
|
||||||
onRefresh: () async {
|
return RefreshIndicator(
|
||||||
// ignore: unused_result
|
onRefresh: () async {
|
||||||
await ref.refresh(browsingHistoryProvider.future);
|
// ignore: unused_result
|
||||||
},
|
await ref.refresh(browsingHistoryProvider.future);
|
||||||
child: HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final groups = useMemoized(
|
|
||||||
() => data.groupListsBy(
|
|
||||||
(element) => timeago.format(
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(element.visitTime),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[EquatableValue(data)],
|
|
||||||
);
|
|
||||||
|
|
||||||
void toggleSelected(VisitInfo item) {
|
|
||||||
if (selectedItems.value.contains(item)) {
|
|
||||||
selectedItems.value = {...selectedItems.value}
|
|
||||||
..remove(item);
|
|
||||||
} else {
|
|
||||||
selectedItems.value = {...selectedItems.value, item};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CustomScrollView(
|
|
||||||
slivers: [
|
|
||||||
for (final MapEntry(:key, :value) in groups.entries)
|
|
||||||
Section(
|
|
||||||
context: context,
|
|
||||||
title: key,
|
|
||||||
items: value,
|
|
||||||
selectedItems: selectedItems.value,
|
|
||||||
onLongPress: toggleSelected,
|
|
||||||
onTap: (item) async {
|
|
||||||
if (selectedItems.value.isNotEmpty) {
|
|
||||||
toggleSelected(item);
|
|
||||||
} else {
|
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.addTab(
|
|
||||||
url: Uri.parse(item.url),
|
|
||||||
private: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
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,
|
||||||
|
onTap: (item) async {
|
||||||
|
if (selectedItems.value.isNotEmpty) {
|
||||||
|
toggleSelected(item);
|
||||||
|
} else {
|
||||||
|
await ref
|
||||||
|
.read(tabRepositoryProvider.notifier)
|
||||||
|
.addTab(
|
||||||
|
url: Uri.parse(item.url),
|
||||||
|
private: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (error, stackTrace) => Center(
|
||||||
|
child: FailureWidget(
|
||||||
|
title: 'Failed to load History',
|
||||||
|
exception: error,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
error: (error, stackTrace) => Center(
|
|
||||||
child: FailureWidget(
|
|
||||||
title: 'Failed to load History',
|
|
||||||
exception: error,
|
|
||||||
),
|
),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user