diff --git a/app/lib/features/settings/data/models/settings.dart b/app/lib/features/settings/data/models/settings.dart index 3e4d6c41..309922fd 100644 --- a/app/lib/features/settings/data/models/settings.dart +++ b/app/lib/features/settings/data/models/settings.dart @@ -1,3 +1,4 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; @@ -10,6 +11,8 @@ class Settings with FastEquatable { final bool incognitoMode; final bool enableJavascript; final bool launchUrlExternal; + final bool enableContentBlocking; + final Set enableHostList; Settings({ required this.kagiSession, @@ -17,6 +20,8 @@ class Settings with FastEquatable { required this.incognitoMode, required this.enableJavascript, required this.launchUrlExternal, + required this.enableContentBlocking, + required this.enableHostList, }); Settings.withDefaults({ @@ -25,10 +30,14 @@ class Settings with FastEquatable { bool? incognitoMode, bool? enableJavascript, bool? launchUrlExternal, + bool? enableContentBlocking, + Set? enableHostList, }) : showEarlyAccessFeatures = showEarlyAccessFeatures ?? true, incognitoMode = incognitoMode ?? true, enableJavascript = enableJavascript ?? true, - launchUrlExternal = launchUrlExternal ?? false; + launchUrlExternal = launchUrlExternal ?? false, + enableContentBlocking = enableContentBlocking ?? true, + enableHostList = enableHostList ?? {HostSource.stevenBlackUnified}; @override bool get cacheHash => true; @@ -40,5 +49,7 @@ class Settings with FastEquatable { incognitoMode, enableJavascript, launchUrlExternal, + enableContentBlocking, + enableHostList, ]; } diff --git a/app/lib/features/settings/data/models/settings.g.dart b/app/lib/features/settings/data/models/settings.g.dart index 36aa2537..71756614 100644 --- a/app/lib/features/settings/data/models/settings.g.dart +++ b/app/lib/features/settings/data/models/settings.g.dart @@ -17,6 +17,10 @@ abstract class _$SettingsCWProxy { Settings launchUrlExternal(bool launchUrlExternal); + Settings enableContentBlocking(bool enableContentBlocking); + + Settings enableHostList(Set enableHostList); + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// /// Usage @@ -29,6 +33,8 @@ abstract class _$SettingsCWProxy { bool? incognitoMode, bool? enableJavascript, bool? launchUrlExternal, + bool? enableContentBlocking, + Set? enableHostList, }); } @@ -57,6 +63,14 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { Settings launchUrlExternal(bool launchUrlExternal) => this(launchUrlExternal: launchUrlExternal); + @override + Settings enableContentBlocking(bool enableContentBlocking) => + this(enableContentBlocking: enableContentBlocking); + + @override + Settings enableHostList(Set enableHostList) => + this(enableHostList: enableHostList); + @override /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Settings(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. @@ -71,6 +85,8 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { Object? incognitoMode = const $CopyWithPlaceholder(), Object? enableJavascript = const $CopyWithPlaceholder(), Object? launchUrlExternal = const $CopyWithPlaceholder(), + Object? enableContentBlocking = const $CopyWithPlaceholder(), + Object? enableHostList = const $CopyWithPlaceholder(), }) { return Settings( kagiSession: kagiSession == const $CopyWithPlaceholder() @@ -98,6 +114,17 @@ class _$SettingsCWProxyImpl implements _$SettingsCWProxy { ? _value.launchUrlExternal // ignore: cast_nullable_to_non_nullable : launchUrlExternal as bool, + enableContentBlocking: + enableContentBlocking == const $CopyWithPlaceholder() || + enableContentBlocking == null + ? _value.enableContentBlocking + // ignore: cast_nullable_to_non_nullable + : enableContentBlocking as bool, + enableHostList: enableHostList == const $CopyWithPlaceholder() || + enableHostList == null + ? _value.enableHostList + // ignore: cast_nullable_to_non_nullable + : enableHostList as Set, ); } } diff --git a/app/lib/features/settings/data/repositories/settings_repository.dart b/app/lib/features/settings/data/repositories/settings_repository.dart index fde66c83..6689bcc7 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.dart +++ b/app/lib/features/settings/data/repositories/settings_repository.dart @@ -1,4 +1,6 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; import 'package:bang_navigator/features/settings/data/models/settings.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,6 +16,8 @@ class SettingsRepository extends _$SettingsRepository { static const _incognitoStorageKey = 'b4ng_settings_incognito'; static const _javascriptStorageKey = 'b4ng_settings_js'; static const _launchExternalStorageKey = 'b4ng_settings_launch_external'; + static const _contentBlockingStorageKey = 'b4ng_settings_content_blocking'; + static const _enableHostListStorageKey = 'b4ng_settings_host_lists'; final FlutterSecureStorage _flutterSecureStorage; final Future _sharedPreferences; @@ -23,6 +27,7 @@ class SettingsRepository extends _$SettingsRepository { _sharedPreferences = SharedPreferences.getInstance(); Future updateSettings(UpdateSettingsFunc updateWithCurrent) async { + final sharedPreferences = await _sharedPreferences; final oldSettings = state.value!; final newSettings = updateWithCurrent(oldSettings); @@ -38,32 +43,48 @@ class SettingsRepository extends _$SettingsRepository { if (newSettings.showEarlyAccessFeatures != oldSettings.showEarlyAccessFeatures) { - await _sharedPreferences.then( - (s) => s.setBool( - _showEarlyAccessFeaturesKey, - newSettings.showEarlyAccessFeatures, - ), + await sharedPreferences.setBool( + _showEarlyAccessFeaturesKey, + newSettings.showEarlyAccessFeatures, ); } if (newSettings.incognitoMode != oldSettings.incognitoMode) { - await _sharedPreferences.then( - (s) => s.setBool(_incognitoStorageKey, newSettings.incognitoMode), + await sharedPreferences.setBool( + _incognitoStorageKey, + newSettings.incognitoMode, ); } if (newSettings.enableJavascript != oldSettings.enableJavascript) { - await _sharedPreferences.then( - (s) => s.setBool(_javascriptStorageKey, newSettings.enableJavascript), + await sharedPreferences.setBool( + _javascriptStorageKey, + newSettings.enableJavascript, ); } if (newSettings.launchUrlExternal != oldSettings.launchUrlExternal) { - await _sharedPreferences.then( - (s) => s.setBool( - _launchExternalStorageKey, - newSettings.launchUrlExternal, - ), + await sharedPreferences.setBool( + _launchExternalStorageKey, + newSettings.launchUrlExternal, + ); + } + + if (newSettings.enableContentBlocking != + oldSettings.enableContentBlocking) { + await sharedPreferences.setBool( + _contentBlockingStorageKey, + newSettings.enableContentBlocking, + ); + } + + if (!const DeepCollectionEquality.unordered().equals( + newSettings.enableHostList, + oldSettings.enableHostList, + )) { + await sharedPreferences.setStringList( + _enableHostListStorageKey, + newSettings.enableHostList.map((list) => list.name).toList(), ); } @@ -82,6 +103,16 @@ class SettingsRepository extends _$SettingsRepository { incognitoMode: sharedPreferences.getBool(_incognitoStorageKey), enableJavascript: sharedPreferences.getBool(_javascriptStorageKey), launchUrlExternal: sharedPreferences.getBool(_launchExternalStorageKey), + enableContentBlocking: + sharedPreferences.getBool(_contentBlockingStorageKey), + enableHostList: sharedPreferences + .getStringList(_enableHostListStorageKey) + ?.map( + (list) => HostSource.values + .firstWhereOrNull((source) => source.name == list), + ) + .whereNotNull() + .toSet(), ); } } diff --git a/app/lib/features/settings/data/repositories/settings_repository.g.dart b/app/lib/features/settings/data/repositories/settings_repository.g.dart index 8985aa2c..24174e57 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.g.dart +++ b/app/lib/features/settings/data/repositories/settings_repository.g.dart @@ -7,7 +7,7 @@ part of 'settings_repository.dart'; // ************************************************************************** String _$settingsRepositoryHash() => - r'0beffd6449e5488e61beb181fefefd4aa24361b0'; + r'9d0e8b8452f85ca33b8f13a4bbcb69212e7d29bd'; /// See also [SettingsRepository]. @ProviderFor(SettingsRepository) diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 35b10a9a..33a36070 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -1,12 +1,13 @@ -import 'package:bang_navigator/core/extension/date_time.dart'; import 'package:bang_navigator/features/bangs/data/models/bang.dart'; import 'package:bang_navigator/features/bangs/domain/providers.dart'; import 'package:bang_navigator/features/bangs/domain/repositories/data.dart'; -import 'package:bang_navigator/features/bangs/domain/repositories/sync.dart'; +import 'package:bang_navigator/features/content_block/data/models/host.dart'; import 'package:bang_navigator/features/settings/data/models/settings.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/features/settings/presentation/controllers/save_settings.dart'; -import 'package:bang_navigator/features/settings/presentation/widgets/button_list_tile.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/bang_group_list_tile.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/host_list_tile.dart'; import 'package:bang_navigator/features/settings/utils/session_link_extractor.dart'; import 'package:bang_navigator/presentation/hooks/listenable_callback.dart'; import 'package:bang_navigator/utils/ui_helper.dart' as ui_helper; @@ -31,39 +32,30 @@ class SettingsScreen extends HookConsumerWidget { ), ); + Widget _buildSubSection(ThemeData theme, String name) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Text( + name, + style: theme.textTheme.titleLarge?.copyWith(fontSize: 18), + ), + ); + @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final kagiSessionTextController = useTextEditingController( - text: ref.read( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.kagiSession), + final settings = ref.watch( + settingsRepositoryProvider.select( + (value) => + value.valueOrNull ?? Settings.withDefaults(kagiSession: null), ), ); + + final kagiSessionTextController = useTextEditingController( + text: settings.kagiSession, + ); final hideSessionText = useState(true); - final showEarlyAccessFeatures = ref.watch( - settingsRepositoryProvider.select( - (value) => value.valueOrNull?.showEarlyAccessFeatures ?? true, - ), - ); - - final incognitoEnabled = ref.watch( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.incognitoMode ?? false), - ); - - final javacsriptEnabled = ref.watch( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.enableJavascript ?? false), - ); - - final launchUrlExternal = ref.watch( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.launchUrlExternal ?? false), - ); - useListenableCallback(kagiSessionTextController, () async { var text = kagiSessionTextController.text; if (Uri.tryParse(text) case final Uri uri) { @@ -142,7 +134,7 @@ class SettingsScreen extends HookConsumerWidget { subtitle: const Text( "Displays Kagi's early access features in the UI. As an Ultimate subscriber, you will likely want to have this enabled.", ), - value: showEarlyAccessFeatures, + value: settings.showEarlyAccessFeatures, onChanged: (value) async { await ref.read(saveSettingsControllerProvider.notifier).save( (currentSettings) => @@ -159,7 +151,7 @@ class SettingsScreen extends HookConsumerWidget { subtitle: const Text( 'Deletes all browsing data upon app restart for enhanced privacy.', ), - value: incognitoEnabled, + value: settings.incognitoMode, onChanged: (value) async { await ref.read(saveSettingsControllerProvider.notifier).save( (currentSettings) => @@ -172,7 +164,7 @@ class SettingsScreen extends HookConsumerWidget { subtitle: const Text( 'While turning off JavaScript boosts security, privacy, and speed, it may cause some sites to not work as intended.', ), - value: javacsriptEnabled, + value: settings.enableJavascript, onChanged: (value) async { await ref.read(saveSettingsControllerProvider.notifier).save( (currentSettings) => @@ -185,7 +177,7 @@ class SettingsScreen extends HookConsumerWidget { subtitle: const Text( 'Opens all links (except for kagi.com) in your default browser.', ), - value: launchUrlExternal, + value: settings.launchUrlExternal, onChanged: (value) async { await ref.read(saveSettingsControllerProvider.notifier).save( (currentSettings) => @@ -196,11 +188,64 @@ class SettingsScreen extends HookConsumerWidget { const SizedBox( height: 16, ), + _buildSection(theme, 'Content Blocking'), + SwitchListTile.adaptive( + title: const Text('Enable Content Blocking'), + subtitle: const Text( + 'Prevents access to unwanted websites and ads, as defined in the selected lists below.', + ), + value: settings.enableContentBlocking, + onChanged: (value) async { + await ref.read(saveSettingsControllerProvider.notifier).save( + (currentSettings) => + currentSettings.copyWith.enableContentBlocking(value), + ); + }, + ), + _buildSubSection(theme, 'Lists'), + HostListTile( + enableContentBlocking: settings.enableContentBlocking, + enableHostLists: settings.enableHostList, + source: HostSource.stevenBlackUnified, + title: 'StevenBlack: Unified', + subtitle: 'Blocks domains containing adware, malware and trackers.', + ), + HostListTile( + enableContentBlocking: settings.enableContentBlocking, + enableHostLists: settings.enableHostList, + source: HostSource.stevenBlackFakeNews, + title: 'StevenBlack: Fake News', + subtitle: 'Blocks domains known for spreading fake news.', + ), + HostListTile( + enableContentBlocking: settings.enableContentBlocking, + enableHostLists: settings.enableHostList, + source: HostSource.stevenBlackGambling, + title: 'StevenBlack: Gambling', + subtitle: 'Blocks domains related to gambling.', + ), + HostListTile( + enableContentBlocking: settings.enableContentBlocking, + enableHostLists: settings.enableHostList, + source: HostSource.stevenBlackPorn, + title: 'StevenBlack: Porn', + subtitle: 'Blocks adult content domains.', + ), + HostListTile( + enableContentBlocking: settings.enableContentBlocking, + enableHostLists: settings.enableHostList, + source: HostSource.stevenBlackSocial, + title: 'StevenBlack: Social', + subtitle: 'Blocks social media domains.', + ), + const SizedBox( + height: 16, + ), _buildSection(theme, 'Bangs'), - ButtonListTile( + CustomListTile( title: 'Bang Frequencies', subtitle: 'Tracked usage for Bang recommendations', - button: FilledButton.icon( + suffix: FilledButton.icon( onPressed: () async { await ref .read(bangDataRepositoryProvider.notifier) @@ -218,7 +263,7 @@ class SettingsScreen extends HookConsumerWidget { ), ); - return ButtonListTile( + return CustomListTile( title: 'Icon Cache', subtitle: 'Stored favicons for Bangs', content: Padding( @@ -226,6 +271,7 @@ class SettingsScreen extends HookConsumerWidget { child: DefaultTextStyle( style: GoogleFonts.robotoMono(), child: Table( + columnWidths: const {0: FixedColumnWidth(100)}, children: [ TableRow( children: [ @@ -237,7 +283,7 @@ class SettingsScreen extends HookConsumerWidget { ), ), ), - button: FilledButton.icon( + suffix: FilledButton.icon( onPressed: () async { await ref .read(bangDataRepositoryProvider.notifier) @@ -249,164 +295,21 @@ class SettingsScreen extends HookConsumerWidget { ); }, ), - Consumer( - builder: (context, ref, child) { - final lastSync = ref.watch( - lastSyncOfGroupProvider(BangGroup.general).select( - (value) => value.valueOrNull, - ), - ); - - final count = ref.watch( - bangCountOfGroupProvider(BangGroup.general).select( - (value) => value.valueOrNull, - ), - ); - - return ButtonListTile( - title: 'General Bangs', - subtitle: 'Automatically syncs every 7 days', - content: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: DefaultTextStyle( - style: GoogleFonts.robotoMono(), - child: Table( - children: [ - TableRow( - children: [ - const Text('Entries'), - Text(count?.toString() ?? 'N/A'), - ], - ), - TableRow( - children: [ - const Text('Last Sync'), - Text( - lastSync?.formatWithMinutePrecision() ?? 'N/A', - ), - ], - ), - ], - ), - ), - ), - button: FilledButton.icon( - onPressed: () async { - await ref - .read(bangSyncRepositoryProvider.notifier) - .syncGeneralBangs(); - }, - icon: const Icon(Icons.sync), - label: const Text('Sync'), - ), - ); - }, + _buildSubSection(theme, 'Repositories'), + const BangGroupListTile( + group: BangGroup.general, + title: 'General Bangs', + subtitle: 'Automatically syncs every 7 days', ), - Consumer( - builder: (context, ref, child) { - final lastSync = ref.watch( - lastSyncOfGroupProvider(BangGroup.assistant).select( - (value) => value.valueOrNull, - ), - ); - - final count = ref.watch( - bangCountOfGroupProvider(BangGroup.assistant).select( - (value) => value.valueOrNull, - ), - ); - - return ButtonListTile( - title: 'Assistant Bangs', - subtitle: 'Automatically syncs every 7 days', - content: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: DefaultTextStyle( - style: GoogleFonts.robotoMono(), - child: Table( - children: [ - TableRow( - children: [ - const Text('Entries'), - Text(count?.toString() ?? 'N/A'), - ], - ), - TableRow( - children: [ - const Text('Last Sync'), - Text( - lastSync?.formatWithMinutePrecision() ?? 'N/A', - ), - ], - ), - ], - ), - ), - ), - button: FilledButton.icon( - onPressed: () async { - await ref - .read(bangSyncRepositoryProvider.notifier) - .syncAssistantBangs(); - }, - icon: const Icon(Icons.sync), - label: const Text('Sync'), - ), - ); - }, + const BangGroupListTile( + group: BangGroup.assistant, + title: 'Assistant Bangs', + subtitle: 'Automatically syncs every 7 days', ), - Consumer( - builder: (context, ref, child) { - final lastSync = ref.watch( - lastSyncOfGroupProvider(BangGroup.kagi).select( - (value) => value.valueOrNull, - ), - ); - - final count = ref.watch( - bangCountOfGroupProvider(BangGroup.kagi).select( - (value) => value.valueOrNull, - ), - ); - - return ButtonListTile( - title: 'Kagi Bangs', - subtitle: 'Automatically syncs every 7 days', - content: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: DefaultTextStyle( - style: GoogleFonts.robotoMono(), - child: Table( - children: [ - TableRow( - children: [ - const Text('Entries'), - Text(count?.toString() ?? 'N/A'), - ], - ), - TableRow( - children: [ - const Text('Last Sync'), - Text( - lastSync?.formatWithMinutePrecision() ?? 'N/A', - ), - ], - ), - ], - ), - ), - ), - button: FilledButton.icon( - onPressed: () async { - await ref - .read(bangSyncRepositoryProvider.notifier) - .syncKagiBangs(); - }, - icon: const Icon(Icons.sync), - label: const Text('Sync'), - ), - ); - }, + const BangGroupListTile( + group: BangGroup.kagi, + title: 'Kagi Bangs', + subtitle: 'Automatically syncs every 7 days', ), ], ), diff --git a/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart b/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart new file mode 100644 index 00000000..94ea39d0 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/bang_group_list_tile.dart @@ -0,0 +1,50 @@ +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/sync.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/sync_details_table.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BangGroupListTile extends HookConsumerWidget { + final BangGroup group; + + final String title; + final String subtitle; + + const BangGroupListTile({ + required this.group, + required this.title, + required this.subtitle, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final lastSync = ref.watch( + lastSyncOfGroupProvider(group).select((value) => value.valueOrNull), + ); + + final count = ref.watch( + bangCountOfGroupProvider(group).select((value) => value.valueOrNull), + ); + + return CustomListTile( + title: title, + subtitle: subtitle, + content: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SyncDetailsTable(count, lastSync), + ), + suffix: FilledButton.icon( + onPressed: () async { + await ref + .read(bangSyncRepositoryProvider.notifier) + .syncBangGroup(group, null); + }, + icon: const Icon(Icons.sync), + label: const Text('Sync'), + ), + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/button_list_tile.dart b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart similarity index 56% rename from app/lib/features/settings/presentation/widgets/button_list_tile.dart rename to app/lib/features/settings/presentation/widgets/custom_list_tile.dart index de3f9b99..68450630 100644 --- a/app/lib/features/settings/presentation/widgets/button_list_tile.dart +++ b/app/lib/features/settings/presentation/widgets/custom_list_tile.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart'; -class ButtonListTile extends StatelessWidget { +class CustomListTile extends StatelessWidget { + final bool enabled; + final String title; final String subtitle; final Widget? content; - final Widget button; + final Widget? prefix; + final Widget suffix; - const ButtonListTile({ + const CustomListTile({ super.key, required this.title, required this.subtitle, this.content, - required this.button, + this.prefix, + required this.suffix, + this.enabled = true, }); @override @@ -23,6 +28,7 @@ class ButtonListTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Row( children: [ + if (prefix != null) prefix!, Expanded( child: Column( mainAxisSize: MainAxisSize.min, @@ -30,17 +36,23 @@ class ButtonListTile extends StatelessWidget { children: [ Text( title, - style: theme.textTheme.bodyLarge, + style: enabled + ? theme.textTheme.bodyLarge + : theme.textTheme.bodyLarge + ?.copyWith(color: theme.disabledColor), ), Text( subtitle, - style: theme.textTheme.bodyMedium, + style: enabled + ? theme.textTheme.bodyMedium + : theme.textTheme.bodyMedium + ?.copyWith(color: theme.disabledColor), ), if (content != null) content!, ], ), ), - button, + suffix, ], ), ); diff --git a/app/lib/features/settings/presentation/widgets/host_list_tile.dart b/app/lib/features/settings/presentation/widgets/host_list_tile.dart new file mode 100644 index 00000000..5001e01d --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/host_list_tile.dart @@ -0,0 +1,83 @@ +import 'package:bang_navigator/features/content_block/data/models/host.dart'; +import 'package:bang_navigator/features/content_block/domain/providers.dart'; +import 'package:bang_navigator/features/content_block/domain/repositories/sync.dart'; +import 'package:bang_navigator/features/settings/data/models/settings.dart'; +import 'package:bang_navigator/features/settings/presentation/controllers/save_settings.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/custom_list_tile.dart'; +import 'package:bang_navigator/features/settings/presentation/widgets/sync_details_table.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class HostListTile extends HookConsumerWidget { + final bool enabled; + + final bool enableContentBlocking; + final Set enableHostLists; + final HostSource source; + + final String title; + final String subtitle; + + const HostListTile({ + required this.enableContentBlocking, + required this.enableHostLists, + required this.source, + required this.title, + required this.subtitle, + this.enabled = true, + super.key, + }); + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = enableHostLists.contains(source); + + final lastSync = ref.watch( + lastSyncOfSourceProvider(source).select((value) => value.valueOrNull), + ); + + final count = ref.watch( + hostCountOfSourceProvider(source).select((value) => value.valueOrNull), + ); + + Future toggleHostLists(HostSource source) async { + final lists = enabled + ? ({...enableHostLists}..remove(source)) + : {...enableHostLists, source}; + + await ref.read(saveSettingsControllerProvider.notifier).save( + (currentSettings) => currentSettings.copyWith.enableHostList(lists), + ); + } + + return CustomListTile( + enabled: enableContentBlocking, + title: title, + subtitle: subtitle, + prefix: Checkbox.adaptive( + value: enableHostLists.contains(source), + onChanged: enableContentBlocking + ? (_) async { + await toggleHostLists(source); + } + : null, + ), + suffix: FilledButton.icon( + onPressed: (enabled && enableContentBlocking) + ? () async { + await ref + .read(hostSyncRepositoryProvider.notifier) + .syncHostSource(source, null); + } + : null, + icon: const Icon(Icons.sync), + label: const Text('Sync'), + ), + content: (enabled && enableContentBlocking) + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SyncDetailsTable(count, lastSync), + ) + : null, + ); + } +} diff --git a/app/lib/features/settings/presentation/widgets/sync_details_table.dart b/app/lib/features/settings/presentation/widgets/sync_details_table.dart new file mode 100644 index 00000000..7951fa64 --- /dev/null +++ b/app/lib/features/settings/presentation/widgets/sync_details_table.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class SyncDetailsTable extends StatelessWidget { + final int? count; + final DateTime? lastSync; + + const SyncDetailsTable(this.count, this.lastSync); + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: GoogleFonts.robotoMono(), + child: Table( + columnWidths: const {0: FixedColumnWidth(100)}, + children: [ + TableRow( + children: [ + const Text('Entries'), + Text(count?.toString() ?? 'N/A'), + ], + ), + TableRow( + children: [ + const Text('Last Sync'), + Text( + (lastSync != null) ? timeago.format(lastSync!) : 'N/A', + ), + ], + ), + ], + ), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 2b405aab..4d499560 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -680,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: @@ -1213,6 +1221,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: d3204eb4c788214883380253da7f23485320a58c11d145babc82ad16bf4e7764 + url: "https://pub.dev" + source: hosted + version: "3.6.1" timing: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9c1bd032..7258d7e9 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: sqlite3: ^2.4.3 sqlite3_flutter_libs: ^0.5.23 text_scroll: ^0.2.0 + timeago: ^3.6.1 universal_io: ^2.2.2 uri_to_file: ^1.0.0 url_launcher: ^6.2.5