diff --git a/app/assets/images/ublock.svg b/app/assets/images/ublock.svg new file mode 100644 index 00000000..ea2ffd3c --- /dev/null +++ b/app/assets/images/ublock.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/app/lib/core/providers/router.dart b/app/lib/core/providers/router.dart index 921f87e2..963ac6da 100644 --- a/app/lib/core/providers/router.dart +++ b/app/lib/core/providers/router.dart @@ -2,10 +2,34 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; part 'router.g.dart'; @Riverpod(keepAlive: true) -GoRouter router(Ref ref) { - return GoRouter(debugLogDiagnostics: true, routes: $appRoutes); +Future router(Ref ref) async { + String? initialLocation; + + final onboardingMandatory = await ref + .read(onboardingRepositoryProvider.notifier) + .isOutdated(); + + if (onboardingMandatory) { + final current = await ref + .read(onboardingRepositoryProvider.notifier) + .getCurrentRevision(); + + final route = OnboardingRoute( + currentRevision: current ?? -1, + targetRevision: OnboardingRepository.targetRevision, + ); + + initialLocation = route.location; + } + + return GoRouter( + debugLogDiagnostics: true, + routes: $appRoutes, + initialLocation: initialLocation, + ); } diff --git a/app/lib/core/providers/router.g.dart b/app/lib/core/providers/router.g.dart index c098746c..fdd8fdc9 100644 --- a/app/lib/core/providers/router.g.dart +++ b/app/lib/core/providers/router.g.dart @@ -6,11 +6,11 @@ part of 'router.dart'; // RiverpodGenerator // ************************************************************************** -String _$routerHash() => r'25fc899b87c080600f6e1d87d510db333378bdfd'; +String _$routerHash() => r'ce7cc5b5f236781a06867a852a08ed03d3582022'; /// See also [router]. @ProviderFor(router) -final routerProvider = Provider.internal( +final routerProvider = FutureProvider.internal( router, name: r'routerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,6 +22,6 @@ final routerProvider = Provider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef RouterRef = ProviderRef; +typedef RouterRef = FutureProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 90b12a45..5d93de80 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -16,6 +16,7 @@ import 'package:weblibre/features/geckoview/features/search/presentation/screens import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart'; +import 'package:weblibre/features/onboarding/presentation/onboarding.dart'; import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/general_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/settings.dart'; @@ -43,3 +44,25 @@ class AboutRoute extends GoRouteData with _$AboutRoute { return DialogPage(builder: (_) => const AboutDialogScreen()); } } + +@TypedGoRoute( + name: 'OnboardingRoute', + path: '/onboarding/:currentRevision/:targetRevision', +) +class OnboardingRoute extends GoRouteData with _$OnboardingRoute { + final int currentRevision; + final int targetRevision; + + const OnboardingRoute({ + required this.currentRevision, + required this.targetRevision, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + return OnboardingScreen( + currentRevision: currentRevision, + targetRevision: targetRevision, + ); + } +} diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 8e195e8c..e2889eb8 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -8,6 +8,7 @@ part of 'routes.dart'; List get $appRoutes => [ $aboutRoute, + $onboardingRoute, $settingsRoute, $browserRoute, $bangCategoriesRoute, @@ -41,6 +42,40 @@ mixin _$AboutRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +RouteBase get $onboardingRoute => GoRouteData.$route( + path: '/onboarding/:currentRevision/:targetRevision', + name: 'OnboardingRoute', + + factory: _$OnboardingRoute._fromState, +); + +mixin _$OnboardingRoute on GoRouteData { + static OnboardingRoute _fromState(GoRouterState state) => OnboardingRoute( + currentRevision: int.parse(state.pathParameters['currentRevision']!)!, + targetRevision: int.parse(state.pathParameters['targetRevision']!)!, + ); + + OnboardingRoute get _self => this as OnboardingRoute; + + @override + String get location => GoRouteData.$location( + '/onboarding/${Uri.encodeComponent(_self.currentRevision.toString())}/${Uri.encodeComponent(_self.targetRevision.toString())}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $settingsRoute => GoRouteData.$route( path: '/settings', name: 'SettingsRoute', diff --git a/app/lib/features/bangs/data/database/daos/bang.dart b/app/lib/features/bangs/data/database/daos/bang.dart index ae90ffa1..f4a5927c 100644 --- a/app/lib/features/bangs/data/database/daos/bang.dart +++ b/app/lib/features/bangs/data/database/daos/bang.dart @@ -34,6 +34,7 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { } Selectable getBangDataList({ + Iterable? triggers, Iterable? groups, String? domain, String? category, @@ -41,6 +42,9 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { bool? orderMostFrequentFirst, }) { final selectable = select(db.bangDataView); + if (triggers != null) { + selectable.where((t) => t.trigger.isIn(triggers)); + } if (groups != null) { selectable.where((t) => t.group.isInValues(groups)); } diff --git a/app/lib/features/bangs/domain/providers/bangs.dart b/app/lib/features/bangs/domain/providers/bangs.dart index 3766db31..5d410f94 100644 --- a/app/lib/features/bangs/domain/providers/bangs.dart +++ b/app/lib/features/bangs/domain/providers/bangs.dart @@ -30,6 +30,7 @@ Stream>> bangCategories(Ref ref) { @Riverpod() Stream> bangList( Ref ref, { + List? triggers, List? groups, String? domain, ({String category, String? subCategory})? categoryFilter, @@ -37,6 +38,7 @@ Stream> bangList( }) { final repository = ref.watch(bangDataRepositoryProvider.notifier); return repository.watchBangs( + triggers: triggers, groups: groups, domain: domain, categoryFilter: categoryFilter, diff --git a/app/lib/features/bangs/domain/providers/bangs.g.dart b/app/lib/features/bangs/domain/providers/bangs.g.dart index 5fb3347a..e8226c18 100644 --- a/app/lib/features/bangs/domain/providers/bangs.g.dart +++ b/app/lib/features/bangs/domain/providers/bangs.g.dart @@ -43,7 +43,7 @@ final bangCategoriesProvider = // ignore: unused_element typedef BangCategoriesRef = AutoDisposeStreamProviderRef>>; -String _$bangListHash() => r'dc168bd1246bea3e5f8015dd52fbf41c5f98ca13'; +String _$bangListHash() => r'd1e0bb9fa4f523ce516e075c0d149bf7803ebb2b'; /// Copied from Dart SDK class _SystemHash { @@ -77,12 +77,14 @@ class BangListFamily extends Family>> { /// See also [bangList]. BangListProvider call({ + List? triggers, List? groups, String? domain, ({String category, String? subCategory})? categoryFilter, bool? orderMostFrequentFirst, }) { return BangListProvider( + triggers: triggers, groups: groups, domain: domain, categoryFilter: categoryFilter, @@ -93,6 +95,7 @@ class BangListFamily extends Family>> { @override BangListProvider getProviderOverride(covariant BangListProvider provider) { return call( + triggers: provider.triggers, groups: provider.groups, domain: provider.domain, categoryFilter: provider.categoryFilter, @@ -119,6 +122,7 @@ class BangListFamily extends Family>> { class BangListProvider extends AutoDisposeStreamProvider> { /// See also [bangList]. BangListProvider({ + List? triggers, List? groups, String? domain, ({String category, String? subCategory})? categoryFilter, @@ -126,6 +130,7 @@ class BangListProvider extends AutoDisposeStreamProvider> { }) : this._internal( (ref) => bangList( ref as BangListRef, + triggers: triggers, groups: groups, domain: domain, categoryFilter: categoryFilter, @@ -138,6 +143,7 @@ class BangListProvider extends AutoDisposeStreamProvider> { : _$bangListHash, dependencies: BangListFamily._dependencies, allTransitiveDependencies: BangListFamily._allTransitiveDependencies, + triggers: triggers, groups: groups, domain: domain, categoryFilter: categoryFilter, @@ -151,12 +157,14 @@ class BangListProvider extends AutoDisposeStreamProvider> { required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, + required this.triggers, required this.groups, required this.domain, required this.categoryFilter, required this.orderMostFrequentFirst, }) : super.internal(); + final List? triggers; final List? groups; final String? domain; final ({String category, String? subCategory})? categoryFilter; @@ -175,6 +183,7 @@ class BangListProvider extends AutoDisposeStreamProvider> { dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, + triggers: triggers, groups: groups, domain: domain, categoryFilter: categoryFilter, @@ -191,6 +200,7 @@ class BangListProvider extends AutoDisposeStreamProvider> { @override bool operator ==(Object other) { return other is BangListProvider && + other.triggers == triggers && other.groups == groups && other.domain == domain && other.categoryFilter == categoryFilter && @@ -200,6 +210,7 @@ class BangListProvider extends AutoDisposeStreamProvider> { @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, triggers.hashCode); hash = _SystemHash.combine(hash, groups.hashCode); hash = _SystemHash.combine(hash, domain.hashCode); hash = _SystemHash.combine(hash, categoryFilter.hashCode); @@ -212,6 +223,9 @@ class BangListProvider extends AutoDisposeStreamProvider> { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin BangListRef on AutoDisposeStreamProviderRef> { + /// The parameter `triggers` of this provider. + List? get triggers; + /// The parameter `groups` of this provider. List? get groups; @@ -230,6 +244,8 @@ class _BangListProviderElement with BangListRef { _BangListProviderElement(super.provider); + @override + List? get triggers => (origin as BangListProvider).triggers; @override List? get groups => (origin as BangListProvider).groups; @override diff --git a/app/lib/features/bangs/domain/repositories/data.dart b/app/lib/features/bangs/domain/repositories/data.dart index 09717ea8..cfe2f9ea 100644 --- a/app/lib/features/bangs/domain/repositories/data.dart +++ b/app/lib/features/bangs/domain/repositories/data.dart @@ -46,6 +46,7 @@ class BangDataRepository extends _$BangDataRepository { } Stream> watchBangs({ + Iterable? triggers, Iterable? groups, String? domain, ({String category, String? subCategory})? categoryFilter, @@ -55,6 +56,7 @@ class BangDataRepository extends _$BangDataRepository { .read(bangDatabaseProvider) .bangDao .getBangDataList( + triggers: triggers, groups: groups, domain: domain, category: categoryFilter?.category, diff --git a/app/lib/features/bangs/domain/repositories/data.g.dart b/app/lib/features/bangs/domain/repositories/data.g.dart index 9fbbca6b..8a997b5f 100644 --- a/app/lib/features/bangs/domain/repositories/data.g.dart +++ b/app/lib/features/bangs/domain/repositories/data.g.dart @@ -7,7 +7,7 @@ part of 'data.dart'; // ************************************************************************** String _$bangDataRepositoryHash() => - r'de89d7089ce6a92015bee233f08d920981518793'; + r'095708cfffe8d0fb604dc02f69e77ae1c050a893'; /// See also [BangDataRepository]. @ProviderFor(BangDataRepository) diff --git a/app/lib/features/geckoview/domain/providers.dart b/app/lib/features/geckoview/domain/providers.dart index d40a2239..e448af11 100644 --- a/app/lib/features/geckoview/domain/providers.dart +++ b/app/lib/features/geckoview/domain/providers.dart @@ -22,7 +22,7 @@ GeckoSelectionActionService selectionActionService(Ref ref) { unawaited( service.setActions([ SearchAction((text) async { - final router = ref.read(routerProvider); + final router = await ref.read(routerProvider.future); final isCurrentPrivate = ref.read(selectedTabStateProvider)?.isPrivate ?? false; final route = SearchRoute( diff --git a/app/lib/features/geckoview/domain/providers.g.dart b/app/lib/features/geckoview/domain/providers.g.dart index 681c9b76..60057ee3 100644 --- a/app/lib/features/geckoview/domain/providers.g.dart +++ b/app/lib/features/geckoview/domain/providers.g.dart @@ -7,7 +7,7 @@ part of 'providers.dart'; // ************************************************************************** String _$selectionActionServiceHash() => - r'c45737a0a403bff1bdb1931b89701300f8dc3726'; + r'456bab83920ae78969bece514d5524e2445e06b6'; /// See also [selectionActionService]. @ProviderFor(selectionActionService) diff --git a/app/lib/features/onboarding/presentation/onboarding.dart b/app/lib/features/onboarding/presentation/onboarding.dart new file mode 100644 index 00000000..a48edb45 --- /dev/null +++ b/app/lib/features/onboarding/presentation/onboarding.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/default_search.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/welcome.dart'; +import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; + +class OnboardingScreen extends HookConsumerWidget { + final int currentRevision; + final int targetRevision; + + const OnboardingScreen({ + super.key, + required this.currentRevision, + required this.targetRevision, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final pageController = usePageController(); + + final pages = useMemoized>(() { + return [ + const WelcomePage(), + const DefaultSearchPage(), + UBlockOptInPage(formKey: GlobalKey()), + ]; + }, [currentRevision, targetRevision]); + + final lastPage = useRef(pageController.initialPage); + final currentPage = useState(pageController.initialPage); + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView( + controller: pageController, + children: pages, + onPageChanged: (value) { + if (value > lastPage.value) { + if (pages[lastPage.value] case final IFormPage formPage) { + formPage.formKey.currentState?.save(); + } + } + + lastPage.value = value; + currentPage.value = value; + }, + ), + ), + Row( + children: [ + Expanded( + child: Visibility( + visible: currentPage.value > 0, + child: TextButton.icon( + onPressed: () async { + await pageController.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + ), + ), + Expanded( + child: Center( + child: SmoothPageIndicator( + controller: pageController, + count: pages.length, + effect: WormEffect( + dotColor: theme.colorScheme.onSurface, + activeDotColor: theme.colorScheme.primary, + ), + ), + ), + ), + if (currentPage.value < pages.length - 1) + Expanded( + child: TextButton.icon( + onPressed: () async { + await pageController.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ) + else + Expanded( + child: TextButton.icon( + onPressed: () async { + if (pages[currentPage.value] + case final IFormPage formPage) { + formPage.formKey.currentState?.save(); + } + + await ref + .read(onboardingRepositoryProvider.notifier) + .pushRevision(targetRevision); + + ref.invalidate(routerProvider); + }, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.done), + label: const Text('Done'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart b/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart new file mode 100644 index 00000000..d8ccb1ff --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/abstract/i_form_page.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +abstract class IFormPage { + GlobalKey get formKey; +} diff --git a/app/lib/features/onboarding/presentation/pages/default_search.dart b/app/lib/features/onboarding/presentation/pages/default_search.dart new file mode 100644 index 00000000..f84f49bc --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/default_search.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +const defaultBangs = ['ddg', 'brave', 'startpage', 'qwant']; + +class DefaultSearchPage extends HookConsumerWidget { + const DefaultSearchPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bangs = ref.watch(bangListProvider(triggers: defaultBangs)); + + final activeBang = ref.watch(defaultSearchBangDataProvider).valueOrNull; + + Future updateSearchProvider(String trigger) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.defaultSearchProvider(trigger), + ); + } + + return bangs.when( + skipLoadingOnReload: true, + data: (availableBangs) { + return ListView( + children: [ + SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Default Search:'), + if (activeBang != null) + FilterChip( + showCheckmark: false, + label: Text(activeBang.websiteName), + avatar: UrlIcon([ + activeBang.getTemplateUrl(''), + ], iconSize: 20), + selected: true, + onSelected: (value) {}, + ), + ], + ), + ), + const Divider(), + Wrap( + spacing: 8.0, + children: [ + ...availableBangs.map( + (bang) => FilterChip( + showCheckmark: false, + label: Text(bang.websiteName), + avatar: UrlIcon([bang.getTemplateUrl('')], iconSize: 20), + selected: activeBang?.trigger == bang.trigger, + onSelected: (selected) async { + if (selected) { + await updateSearchProvider(bang.trigger); + } + }, + ), + ), + ActionChip( + label: const Text('Search more'), + avatar: const Icon(Icons.search), + onPressed: () async { + final trigger = await const BangSearchRoute().push( + context, + ); + + if (trigger != null) { + await updateSearchProvider(trigger); + } + }, + ), + ], + ), + ], + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48, width: double.infinity), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart new file mode 100644 index 00000000..c4fd8448 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/ublock_opt_in.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; +import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; + +class UBlockOptInPage extends HookConsumerWidget implements IFormPage { + @override + final GlobalKey formKey; + + const UBlockOptInPage({super.key, required this.formKey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Form( + key: formKey, + child: ListView( + children: [ + SvgPicture.asset('assets/images/ublock.svg'), + const SizedBox(height: 8), + Center( + child: Text('uBlock Origin', style: theme.textTheme.headlineLarge), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: MarkdownBody( + data: ''' +uBlock Origin (uBO) is a CPU and memory-efficient **wide-spectrum content blocker** by **Raymond Hill** and available as a browser extensions for WebLibre. + +It blocks ads, trackers, coin miners, popups, annoying anti-blockers, malware sites, etc., by default using **EasyList, EasyPrivacy, Peter Lowe's Blocklist, Online Malicious URL Blocklist, and uBO filter lists**. + +There are many other lists available to block even more. + ''', + ), + ), + const SizedBox(height: 24), + FormField( + initialValue: true, + onSaved: (newValue) { + if (newValue == true) { + unawaited( + ref + .read(browserAddonServiceProvider.notifier) + .install('uBlock0@raymondhill.net'), + ); + } + }, + builder: (field) => SwitchListTile( + value: field.value ?? false, + title: const Text('Install uBlock Origin Extension'), + onChanged: (value) { + field.didChange(value); + }, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/onboarding/presentation/pages/welcome.dart b/app/lib/features/onboarding/presentation/pages/welcome.dart new file mode 100644 index 00000000..eb6c8467 --- /dev/null +++ b/app/lib/features/onboarding/presentation/pages/welcome.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class WelcomePage extends StatelessWidget { + const WelcomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FractionallySizedBox( + widthFactor: 0.5, + child: Image.asset('assets/icon/icon.png'), + ), + Text( + 'The Privacy-Focused Browser', + softWrap: true, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineLarge, + ), + ], + ); + } +} diff --git a/app/lib/features/user/data/database/daos/onboarding.dart b/app/lib/features/user/data/database/daos/onboarding.dart new file mode 100644 index 00000000..3988c2a0 --- /dev/null +++ b/app/lib/features/user/data/database/daos/onboarding.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/user/data/database/database.dart'; + +part 'onboarding.g.dart'; + +@DriftAccessor() +class OnboardingDao extends DatabaseAccessor + with _$OnboardingDaoMixin { + OnboardingDao(super.attachedDatabase); + + SingleOrNullSelectable getLastRevision() { + final maxRevision = db.onboarding.revision.max(); + + final query = selectOnly(db.onboarding)..addColumns([maxRevision]); + + return query.map((row) => row.read(maxRevision)); + } + + Future pushRevision(int revision, DateTime completionDate) { + return db.onboarding.insertOne( + OnboardingCompanion.insert( + revision: revision, + completionDate: completionDate, + ), + mode: InsertMode.insertOrReplace, + ); + } +} diff --git a/app/lib/features/user/data/database/daos/onboarding.g.dart b/app/lib/features/user/data/database/daos/onboarding.g.dart new file mode 100644 index 00000000..17352560 --- /dev/null +++ b/app/lib/features/user/data/database/daos/onboarding.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding.dart'; + +// ignore_for_file: type=lint +mixin _$OnboardingDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/user/data/database/database.dart b/app/lib/features/user/data/database/database.dart index 14302137..8d57e579 100644 --- a/app/lib/features/user/data/database/database.dart +++ b/app/lib/features/user/data/database/database.dart @@ -1,10 +1,14 @@ import 'package:drift/drift.dart'; import 'package:weblibre/features/user/data/database/daos/cache.dart'; +import 'package:weblibre/features/user/data/database/daos/onboarding.dart'; import 'package:weblibre/features/user/data/database/daos/setting.dart'; part 'database.g.dart'; -@DriftDatabase(include: {'database.drift'}, daos: [SettingDao, CacheDao]) +@DriftDatabase( + include: {'database.drift'}, + daos: [SettingDao, CacheDao, OnboardingDao], +) class UserDatabase extends _$UserDatabase { @override final int schemaVersion = 1; diff --git a/app/lib/features/user/data/database/database.drift b/app/lib/features/user/data/database/database.drift index 35400919..af338803 100644 --- a/app/lib/features/user/data/database/database.drift +++ b/app/lib/features/user/data/database/database.drift @@ -10,6 +10,11 @@ CREATE TABLE icon_cache ( fetch_date DATETIME NOT NULL ); +CREATE TABLE onboarding ( + revision INTEGER NOT NULL, + completion_date DATETIME NOT NULL +); + evictCacheEntries: DELETE FROM icon_cache WHERE rowid IN ( diff --git a/app/lib/features/user/data/database/database.g.dart b/app/lib/features/user/data/database/database.g.dart index 7fa36ca1..44a3e5ad 100644 --- a/app/lib/features/user/data/database/database.g.dart +++ b/app/lib/features/user/data/database/database.g.dart @@ -446,13 +446,199 @@ class IconCacheCompanion extends UpdateCompanion { } } +class Onboarding extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Onboarding(this.attachedDatabase, [this._alias]); + late final GeneratedColumn revision = GeneratedColumn( + 'revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn completionDate = + GeneratedColumn( + 'completion_date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [revision, completionDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'onboarding'; + @override + Set get $primaryKey => const {}; + @override + OnboardingData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return OnboardingData( + revision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}revision'], + )!, + completionDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}completion_date'], + )!, + ); + } + + @override + Onboarding createAlias(String alias) { + return Onboarding(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class OnboardingData extends DataClass implements Insertable { + final int revision; + final DateTime completionDate; + const OnboardingData({required this.revision, required this.completionDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['revision'] = Variable(revision); + map['completion_date'] = Variable(completionDate); + return map; + } + + factory OnboardingData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return OnboardingData( + revision: serializer.fromJson(json['revision']), + completionDate: serializer.fromJson(json['completion_date']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'revision': serializer.toJson(revision), + 'completion_date': serializer.toJson(completionDate), + }; + } + + OnboardingData copyWith({int? revision, DateTime? completionDate}) => + OnboardingData( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + ); + OnboardingData copyWithCompanion(OnboardingCompanion data) { + return OnboardingData( + revision: data.revision.present ? data.revision.value : this.revision, + completionDate: data.completionDate.present + ? data.completionDate.value + : this.completionDate, + ); + } + + @override + String toString() { + return (StringBuffer('OnboardingData(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(revision, completionDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnboardingData && + other.revision == this.revision && + other.completionDate == this.completionDate); +} + +class OnboardingCompanion extends UpdateCompanion { + final Value revision; + final Value completionDate; + final Value rowid; + const OnboardingCompanion({ + this.revision = const Value.absent(), + this.completionDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + OnboardingCompanion.insert({ + required int revision, + required DateTime completionDate, + this.rowid = const Value.absent(), + }) : revision = Value(revision), + completionDate = Value(completionDate); + static Insertable custom({ + Expression? revision, + Expression? completionDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (revision != null) 'revision': revision, + if (completionDate != null) 'completion_date': completionDate, + if (rowid != null) 'rowid': rowid, + }); + } + + OnboardingCompanion copyWith({ + Value? revision, + Value? completionDate, + Value? rowid, + }) { + return OnboardingCompanion( + revision: revision ?? this.revision, + completionDate: completionDate ?? this.completionDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (revision.present) { + map['revision'] = Variable(revision.value); + } + if (completionDate.present) { + map['completion_date'] = Variable(completionDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('OnboardingCompanion(') + ..write('revision: $revision, ') + ..write('completionDate: $completionDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$UserDatabase extends GeneratedDatabase { _$UserDatabase(QueryExecutor e) : super(e); $UserDatabaseManager get managers => $UserDatabaseManager(this); late final Setting setting = Setting(this); late final IconCache iconCache = IconCache(this); + late final Onboarding onboarding = Onboarding(this); late final SettingDao settingDao = SettingDao(this as UserDatabase); late final CacheDao cacheDao = CacheDao(this as UserDatabase); + late final OnboardingDao onboardingDao = OnboardingDao(this as UserDatabase); Future evictCacheEntries({required int limit}) { return customUpdate( 'DELETE FROM icon_cache WHERE "rowid" IN (SELECT "rowid" FROM icon_cache ORDER BY fetch_date DESC LIMIT -1 OFFSET ?1)', @@ -466,7 +652,11 @@ abstract class _$UserDatabase extends GeneratedDatabase { Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [setting, iconCache]; + List get allSchemaEntities => [ + setting, + iconCache, + onboarding, + ]; } typedef $SettingCreateCompanionBuilder = @@ -780,6 +970,149 @@ typedef $IconCacheProcessedTableManager = IconCacheData, PrefetchHooks Function() >; +typedef $OnboardingCreateCompanionBuilder = + OnboardingCompanion Function({ + required int revision, + required DateTime completionDate, + Value rowid, + }); +typedef $OnboardingUpdateCompanionBuilder = + OnboardingCompanion Function({ + Value revision, + Value completionDate, + Value rowid, + }); + +class $OnboardingFilterComposer extends Composer<_$UserDatabase, Onboarding> { + $OnboardingFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get revision => $composableBuilder( + column: $table.revision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => ColumnFilters(column), + ); +} + +class $OnboardingOrderingComposer extends Composer<_$UserDatabase, Onboarding> { + $OnboardingOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get revision => $composableBuilder( + column: $table.revision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => ColumnOrderings(column), + ); +} + +class $OnboardingAnnotationComposer + extends Composer<_$UserDatabase, Onboarding> { + $OnboardingAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get revision => + $composableBuilder(column: $table.revision, builder: (column) => column); + + GeneratedColumn get completionDate => $composableBuilder( + column: $table.completionDate, + builder: (column) => column, + ); +} + +class $OnboardingTableManager + extends + RootTableManager< + _$UserDatabase, + Onboarding, + OnboardingData, + $OnboardingFilterComposer, + $OnboardingOrderingComposer, + $OnboardingAnnotationComposer, + $OnboardingCreateCompanionBuilder, + $OnboardingUpdateCompanionBuilder, + ( + OnboardingData, + BaseReferences<_$UserDatabase, Onboarding, OnboardingData>, + ), + OnboardingData, + PrefetchHooks Function() + > { + $OnboardingTableManager(_$UserDatabase db, Onboarding table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $OnboardingFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $OnboardingOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $OnboardingAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value revision = const Value.absent(), + Value completionDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => OnboardingCompanion( + revision: revision, + completionDate: completionDate, + rowid: rowid, + ), + createCompanionCallback: + ({ + required int revision, + required DateTime completionDate, + Value rowid = const Value.absent(), + }) => OnboardingCompanion.insert( + revision: revision, + completionDate: completionDate, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $OnboardingProcessedTableManager = + ProcessedTableManager< + _$UserDatabase, + Onboarding, + OnboardingData, + $OnboardingFilterComposer, + $OnboardingOrderingComposer, + $OnboardingAnnotationComposer, + $OnboardingCreateCompanionBuilder, + $OnboardingUpdateCompanionBuilder, + ( + OnboardingData, + BaseReferences<_$UserDatabase, Onboarding, OnboardingData>, + ), + OnboardingData, + PrefetchHooks Function() + >; class $UserDatabaseManager { final _$UserDatabase _db; @@ -787,4 +1120,6 @@ class $UserDatabaseManager { $SettingTableManager get setting => $SettingTableManager(_db, _db.setting); $IconCacheTableManager get iconCache => $IconCacheTableManager(_db, _db.iconCache); + $OnboardingTableManager get onboarding => + $OnboardingTableManager(_db, _db.onboarding); } diff --git a/app/lib/features/user/domain/repositories/onboarding.dart b/app/lib/features/user/domain/repositories/onboarding.dart new file mode 100644 index 00000000..36b40fe2 --- /dev/null +++ b/app/lib/features/user/domain/repositories/onboarding.dart @@ -0,0 +1,34 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'onboarding.g.dart'; + +@Riverpod(keepAlive: true) +class OnboardingRepository extends _$OnboardingRepository { + static const targetRevision = 1; + + Future getCurrentRevision() { + return ref + .read(userDatabaseProvider) + .onboardingDao + .getLastRevision() + .getSingleOrNull(); + } + + Future pushRevision(int revision) { + return ref + .read(userDatabaseProvider) + .onboardingDao + .pushRevision(revision, DateTime.now()); + } + + Future isOutdated() async { + final current = await getCurrentRevision(); + return current != targetRevision; + } + + @override + void build() { + return; + } +} diff --git a/app/lib/features/user/domain/repositories/onboarding.g.dart b/app/lib/features/user/domain/repositories/onboarding.g.dart new file mode 100644 index 00000000..ad730489 --- /dev/null +++ b/app/lib/features/user/domain/repositories/onboarding.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onboardingRepositoryHash() => + r'37b76aa313ae6dfed6ab5695b4df8c4a0cfe0483'; + +/// See also [OnboardingRepository]. +@ProviderFor(OnboardingRepository) +final onboardingRepositoryProvider = + NotifierProvider.internal( + OnboardingRepository.new, + name: r'onboardingRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$onboardingRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$OnboardingRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/presentation/main_app.dart b/app/lib/presentation/main_app.dart index eec28f10..b45dedb1 100644 --- a/app/lib/presentation/main_app.dart +++ b/app/lib/presentation/main_app.dart @@ -52,7 +52,7 @@ class MainApp extends HookConsumerWidget { theme: theme, darkTheme: darkTheme, themeMode: themeMode, - routerConfig: router, + routerConfig: router.valueOrNull, ); }, onFailure: (errorMessage) { diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 61753673..3e64de66 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: path: ../packages/simple_intent_receiver skeletonizer: ^2.0.1 sliver_tools: ^0.2.12 + smooth_page_indicator: ^1.2.1 speech_to_text_google_dialog: git: url: https://github.com/FaFre/speech_to_text_google_dialog.git