diff --git a/README.md b/README.md index 27e30ac0..44c6700f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ By combining these tools, WebLibre empowers you to browse the web freely and pri - **DNS over HTTPS (DoH):** Encrypted DNS with predefined or custom resolver options - **Container Data Isolation:** Clear container data for isolated containers +### [Small Web Discovery](https://docs.weblibre.eu/small-web) + +- **Discover the Small Web:** A dedicated browsing mode for discovering personal blogs, indie creators, and handcrafted websites — the hidden corners of the internet made by real people +- **Kagi Small Web:** Browse curated content from thousands of community-vetted personal blogs, indie YouTube channels, and web comics across five modes (Web, Appreciated, Videos, Code, Comics) +- **Wander Network:** Explore a decentralised network of personal websites where each site recommends pages and links to other sites, forming a growing web of discoveries +- **Category Filters:** Narrow discoveries by topic — from Programming and Science to Art, Food, Travel, and more +- **Discovery History:** Track and revisit pages you have found, with smart avoidance of recently seen content + ### Productivity & Content Management - **Bookmark Management** - Import/export bookmarks (JSON/HTML format) diff --git a/app/assets/legal/PRIVACY_POLICY.md b/app/assets/legal/PRIVACY_POLICY.md index e6e7d01d..973ab05e 100644 --- a/app/assets/legal/PRIVACY_POLICY.md +++ b/app/assets/legal/PRIVACY_POLICY.md @@ -1,7 +1,7 @@ # WebLibre Privacy Policy -**Version 1.2** -**Last Updated:** 2026-03-15 +**Version 1.3** +**Last Updated:** 2026-03-22 **Controller:** OnDevice UG (haftungsbeschränkt) @@ -135,7 +135,19 @@ If you use speech-to-text or voice-input features, WebLibre invokes Android's sp **Legal basis:** Consent (Art. 6(1)(a) GDPR) — you choose to invoke the feature. -### 3.13 QR Scanning and Camera-Based Pairing (Optional) +### 3.13 Small Web Discovery (Optional) + +When you use Small Web mode, WebLibre fetches content from third-party sources to help you discover independent websites and personal blogs. + +**Kagi Small Web:** WebLibre fetches publicly available Atom feeds from Kagi (`kagi.com`) to retrieve curated lists of personal blog posts, indie videos, code projects, and web comics. Feed content is cached locally on your device. + +**Wander:** WebLibre fetches small JavaScript files (`wander.js`) from independently operated personal websites that participate in the Wander network. These files contain lists of recommended pages and links to other participating sites. Your device connects directly to each console host. + +**Data sent:** Standard HTTP(S) requests to Kagi's feed endpoints and to Wander console hosts. Your IP address is visible to these servers. Discovered content (URLs, titles, authors, summaries) is stored locally on your device. No account or personal data is sent beyond what is included in standard HTTP requests. + +**Legal basis:** Consent (Art. 6(1)(a) GDPR) — you choose to enter Small Web mode and initiate discovery. + +### 3.14 QR Scanning and Camera-Based Pairing (Optional) If you use QR scanning features (such as pairing a Firefox Sync account from a desktop browser), the app uses the device camera locally to scan the code. @@ -167,7 +179,7 @@ Where applicable under the GDPR, we process personal data on one or more of the **Summary of legal bases:** -- **Consent** (Art. 6(1)(a) GDPR) — for optional features you choose to enable, such as search suggestions (3.2), Firefox Sync (3.4), DNS-over-HTTPS (3.5), URL unshortening (3.8), Tor routing (3.9), URL cleaner catalog updates (3.11), voice input (3.12), and QR scanning (3.13); +- **Consent** (Art. 6(1)(a) GDPR) — for optional features you choose to enable, such as search suggestions (3.2), Firefox Sync (3.4), DNS-over-HTTPS (3.5), URL unshortening (3.8), Tor routing (3.9), URL cleaner catalog updates (3.11), voice input (3.12), Small Web discovery (3.13), and QR scanning (3.14); - **Contract performance** (Art. 6(1)(b) GDPR) — for features inherent to the service you requested, such as web browsing (3.1), search queries (3.3), extension installation (3.7), and feed fetching (3.10); - **Legitimate interest** (Art. 6(1)(f) GDPR) — for security and protection features such as tracking protection and safe browsing (3.6); - **Legal obligation** (Art. 6(1)(c) GDPR) — where required by applicable law; diff --git a/app/assets/small_web/kagi_categories.json b/app/assets/small_web/kagi_categories.json new file mode 100644 index 00000000..1195f62b --- /dev/null +++ b/app/assets/small_web/kagi_categories.json @@ -0,0 +1,148 @@ +{ + "categories": { + "ai": { + "label": "AI", + "description": "LLMs · agents · prompts · AI ethics · AI safety", + "emoji": "✨" + }, + "programming": { + "label": "Programming", + "description": "Code · frameworks · devtools · APIs · tutorials · open source", + "emoji": "🧩" + }, + "tech": { + "label": "Technology", + "description": "Tech news · gadgets · apps · platforms · social media", + "emoji": "📱" + }, + "infra": { + "label": "Sysadmin & Security", + "description": "Servers · cloud · containers · networking · infosec · privacy", + "emoji": "🛡️" + }, + "web": { + "label": "Web & Internet", + "description": "The open web · RSS · blogging · IndieWeb · web standards", + "emoji": "🕸️" + }, + "hardware": { + "label": "Hardware", + "description": "Electronics · home lab · PCB design · keyboards · audio gear", + "emoji": "🎛️" + }, + "diy": { + "label": "DIY & Making", + "description": "Woodworking · metalworking · 3D printing · home · maker", + "emoji": "🛠️" + }, + "retro": { + "label": "Retro", + "description": "Vintage computers · DOS · BBS · demoscene · old software", + "emoji": "💾" + }, + "science": { + "label": "Science", + "description": "Physics · biology · math · space · climate · research", + "emoji": "⚛️" + }, + "humanities": { + "label": "Humanities", + "description": "History · philosophy · linguistics · archaeology · classics", + "emoji": "🏺" + }, + "essays": { + "label": "Essays", + "description": "Long-form reflective writing that defies topic categories", + "emoji": "🪶" + }, + "art": { + "label": "Art & Design", + "description": "Visual art · illustration · typography · creative writing", + "emoji": "🌊" + }, + "photography": { + "label": "Photography", + "description": "Photographic craft · technique · gear · film · photo essays", + "emoji": "🌄" + }, + "culture": { + "label": "Pop Culture", + "description": "Film · TV · music · books · comics", + "emoji": "🎭" + }, + "gaming": { + "label": "Gaming", + "description": "Video games · game dev · modding · interactive fiction", + "emoji": "🕹️" + }, + "politics": { + "label": "Politics", + "description": "Government · legislation · international relations", + "emoji": "🎤" + }, + "economy": { + "label": "Economy", + "description": "Markets · finance · labor economics · trade · analysis", + "emoji": "🎲" + }, + "society": { + "label": "Society", + "description": "Discrimination · civil rights · social structure", + "emoji": "👥" + }, + "life": { + "label": "Life & Personal", + "description": "Diary · weeknotes · parenting · pets · link roundups", + "emoji": "☀️" + }, + "food": { + "label": "Food & Drink", + "description": "Recipes · cooking · restaurants · coffee · wine · baking", + "emoji": "🧑‍🍳" + }, + "travel": { + "label": "Travel & Outdoors", + "description": "Trip reports · hiking · nature · birdwatching · gardening", + "emoji": "✈️" + }, + "health": { + "label": "Health & Fitness", + "description": "Fitness · nutrition · mental health · longevity", + "emoji": "🏃" + } + }, + "groups": { + "Tech & Science": [ + "ai", + "science", + "programming", + "diy", + "tech", + "hardware", + "infra", + "web" + ], + "Culture & Creative": [ + "health", + "art", + "essays", + "humanities", + "retro", + "photography", + "culture", + "gaming" + ], + "Life & World": [ + "society", + "life", + "food", + "travel", + "politics", + "economy" + ] + }, + "remap": { + "sysadmin": "infra", + "security": "infra" + } +} \ No newline at end of file diff --git a/app/lib/data/database/converters/string_list.dart b/app/lib/data/database/converters/string_list.dart index 50c5589d..d4a7b973 100644 --- a/app/lib/data/database/converters/string_list.dart +++ b/app/lib/data/database/converters/string_list.dart @@ -26,7 +26,7 @@ class StringListConverter extends TypeConverter, String> { @override List fromSql(String fromDb) { - return jsonDecode(fromDb) as List; + return (jsonDecode(fromDb) as List).cast(); } @override diff --git a/app/lib/features/bangs/data/providers.g.dart b/app/lib/features/bangs/data/providers.g.dart index acb1d7e4..01a6e161 100644 --- a/app/lib/features/bangs/data/providers.g.dart +++ b/app/lib/features/bangs/data/providers.g.dart @@ -48,4 +48,4 @@ final class BangDatabaseProvider } } -String _$bangDatabaseHash() => r'5a51a8c1db43e46f0adc0b08def0dc44b9b2dfd4'; +String _$bangDatabaseHash() => r'0369d508def140a32c08c0551cefed57b1ca4b26'; diff --git a/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart index 238babe9..30cd4624 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart @@ -140,3 +140,23 @@ void _collectAllDescendantGuids(BookmarkFolder folder, Set result) { } } } + +/// Returns GUIDs of all bookmark entries matching [url] in the tree. +List bookmarkGuidsForUrl(BookmarkItem? root, Uri? url) { + final result = []; + if (root == null || url == null) return result; + + void collect(BookmarkItem item) { + if (item is BookmarkEntry && item.url == url) { + result.add(item.guid); + } + if (item is BookmarkFolder) { + for (final child in item.children ?? const []) { + collect(child); + } + } + } + + collect(root); + return result; +} diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart index 879d2a97..69203de3 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart @@ -42,7 +42,7 @@ final class BrowserAddonServiceProvider } String _$browserAddonServiceHash() => - r'1485fd056ce32e142e342e920affb82a5c77a370'; + r'6f07d93576a0816bf1b9e1b36397b89b7cacf43f'; abstract class _$BrowserAddonService extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index 201322dc..a3b1bd0c 100644 --- a/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/app/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +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'; @@ -30,8 +31,8 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart'; @@ -689,11 +690,17 @@ class _BookmarkToolbarButton extends HookConsumerWidget { final tabUrl = scope.tabState?.url; final bookmarkable = tabUrl != null && !scope.isPreview; - final existingGuids = ref.watch( - bookmarksRepositoryProvider.select( - (async) => _bookmarkGuidsForUrl(async.value, tabUrl, bookmarkable), - ), - ); + final existingGuids = ref + .watch( + bookmarksRepositoryProvider.select( + (async) => EquatableValue( + bookmarkable + ? bookmarkGuidsForUrl(async.value, tabUrl) + : const [], + ), + ), + ) + .value; final isBookmarked = existingGuids.isNotEmpty; @@ -769,11 +776,18 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabUrl = scope.tabState?.url; final bookmarkable = tabUrl != null && !scope.isPreview; - final existingGuids = ref.watch( - bookmarksRepositoryProvider.select( - (async) => _bookmarkGuidsForUrl(async.value, tabUrl, bookmarkable), - ), - ); + final existingGuids = ref + .watch( + bookmarksRepositoryProvider.select( + (async) => EquatableValue( + bookmarkable + ? bookmarkGuidsForUrl(async.value, tabUrl) + : const [], + ), + ), + ) + .value; + final isBookmarked = existingGuids.isNotEmpty; return IconButton( @@ -821,33 +835,6 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget { } } -List _bookmarkGuidsForUrl( - BookmarkItem? root, - Uri? tabUrl, - bool bookmarkable, -) { - final result = []; - - if (!bookmarkable || root == null || tabUrl == null) { - return result; - } - - void collect(BookmarkItem item) { - if (item is BookmarkEntry && item.url == tabUrl) { - result.add(item.guid); - } - - if (item is BookmarkFolder) { - for (final child in item.children ?? const []) { - collect(child); - } - } - } - - collect(root); - return result; -} - Future _adjustFontSize( BuildContext context, WidgetRef ref, { diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index e25d27a6..38936f20 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -56,6 +56,8 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -115,6 +117,8 @@ class _TabBar extends HookConsumerWidget { final bool showQuickTabSwitcherBar; final Stream? pointerMoveEvents; final TabBarPosition tabBarPosition; + final bool isSmallWebMode; + final bool enableGestures; const _TabBar({ required this.showMainToolbar, @@ -122,6 +126,8 @@ class _TabBar extends HookConsumerWidget { required this.showQuickTabSwitcherBar, required this.tabBarPosition, required this.pointerMoveEvents, + required this.isSmallWebMode, + this.enableGestures = true, }); @override @@ -205,12 +211,15 @@ class _TabBar extends HookConsumerWidget { showMainToolbar: showMainToolbar, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: showQuickTabSwitcherBar, + isSmallWebMode: isSmallWebMode, + enableGestures: enableGestures, ), TabBarPosition.bottom => BrowserBottomAppBar( displayedSheet: displayedSheet, showMainToolbar: showMainToolbar, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: showQuickTabSwitcherBar, + isSmallWebMode: isSmallWebMode, ), }; } @@ -233,23 +242,33 @@ class BrowserScreen extends HookConsumerWidget { final overlayController = useOverlayPortalController(); - final tabBarPosition = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.tabBarPosition, - ), + final isSmallWebActive = ref.watch( + smallWebModeControllerProvider.select((value) => value != null), ); - final showContextualToolbar = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.tabBarShowContextualBar, - ), - ); + final tabBarPosition = isSmallWebActive + ? TabBarPosition.top + : ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarPosition, + ), + ); - final showQuickTabSwitcherBar = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.tabBarShowQuickTabSwitcherBar, - ), - ); + final showContextualToolbar = + !isSmallWebActive && + ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarShowContextualBar, + ), + ); + + final showQuickTabSwitcherBar = + !isSmallWebActive && + ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarShowQuickTabSwitcherBar, + ), + ); final quickTabSwitcherMode = ref.watch( generalSettingsWithDefaultsProvider.select( (value) => value.effectiveUiQuickTabSwitcherMode(), @@ -261,11 +280,13 @@ class BrowserScreen extends HookConsumerWidget { final displayQuickTabSwitcherBar = showQuickTabSwitcherBar && (quickTabSwitcherHasResults.value ?? false); - final autoHideTabBar = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.autoHideTabBar, - ), - ); + final autoHideTabBar = + !isSmallWebActive && + ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.autoHideTabBar, + ), + ); ref.listen(overlayControllerProvider, (previous, next) { if (next != null) { @@ -338,13 +359,21 @@ class BrowserScreen extends HookConsumerWidget { final bottomSafeArea = MediaQuery.of(context).padding.bottom; // Calculate bottom toolbar size for FAB and sheet positioning - // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar - final bottomAppBarContentSize = BrowserBottomAppBar( - showMainToolbar: tabBarPosition == TabBarPosition.bottom, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: displayQuickTabSwitcherBar, - displayedSheet: displayedSheet, - ).preferredSize; + final Size bottomAppBarContentSize; + if (isSmallWebActive) { + bottomAppBarContentSize = const Size.fromHeight( + SmallWebBrowserOverlay.barHeight, + ); + } else { + // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar + bottomAppBarContentSize = BrowserBottomAppBar( + showMainToolbar: tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: displayQuickTabSwitcherBar, + isSmallWebMode: false, + displayedSheet: displayedSheet, + ).preferredSize; + } // Total height includes safe area padding final bottomAppBarTotalHeight = bottomAppBarContentSize.height + bottomSafeArea; @@ -355,6 +384,8 @@ class BrowserScreen extends HookConsumerWidget { showMainToolbar: tabBarPosition == TabBarPosition.top, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: displayQuickTabSwitcherBar, + isSmallWebMode: isSmallWebActive, + enableGestures: !isSmallWebActive, ).preferredSize; final topAppBarTotalHeight = topAppBarContentSize.height + topSafeArea; @@ -623,36 +654,49 @@ class BrowserScreen extends HookConsumerWidget { ), // Layer 2: Bottom Toolbar (overlay, slides in/out) + // In small web mode, show the discovery overlay instead Positioned( left: 0, right: 0, bottom: 0, - child: Consumer( - builder: (context, ref, _) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return _AnimatedToolbar( - position: TabBarPosition.bottom, - visible: visible, - child: _TabBar( - tabBarPosition: TabBarPosition.bottom, - showMainToolbar: - tabBarPosition == TabBarPosition.bottom, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: displayQuickTabSwitcherBar, - pointerMoveEvents: - tabBarPosition == TabBarPosition.bottom - ? pointerMoveEventsController.stream - : null, + child: isSmallWebActive + ? _AnimatedToolbar( + position: TabBarPosition.bottom, + visible: !tabInFullScreen, + child: Material( + color: Theme.of(context).colorScheme.surfaceContainer, + elevation: 3, + child: const SmallWebBrowserOverlay(), + ), + ) + : Consumer( + builder: (context, ref, _) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && + toolbarState == ToolbarVisibility.visible); + return _AnimatedToolbar( + position: TabBarPosition.bottom, + visible: visible, + child: _TabBar( + tabBarPosition: TabBarPosition.bottom, + showMainToolbar: + tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: + displayQuickTabSwitcherBar, + isSmallWebMode: false, + pointerMoveEvents: + tabBarPosition == TabBarPosition.bottom + ? pointerMoveEventsController.stream + : null, + ), + ); + }, ), - ); - }, - ), ), // Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top @@ -678,7 +722,11 @@ class BrowserScreen extends HookConsumerWidget { showMainToolbar: true, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: displayQuickTabSwitcherBar, - pointerMoveEvents: pointerMoveEventsController.stream, + isSmallWebMode: isSmallWebActive, + enableGestures: !isSmallWebActive, + pointerMoveEvents: isSmallWebActive + ? null + : pointerMoveEventsController.stream, ), ); }, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index a0ed08a9..430c0ec8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -66,6 +66,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart' import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; @@ -1918,7 +1919,19 @@ class _QuickLinksGrid extends ConsumerWidget { }), ), const SizedBox(width: 8), - const Expanded(child: SizedBox.shrink()), + Expanded( + child: _buildGridItem( + context, + Icons.explore, + 'Small Web', + () async { + Navigator.pop(context); + await ref + .read(smallWebModeControllerProvider.notifier) + .enter(); + }, + ), + ), const SizedBox(width: 8), const Expanded(child: SizedBox.shrink()), ], diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index ad058f87..c8211114 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -40,6 +40,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; @@ -52,6 +55,8 @@ class BrowserTopAppBar extends StatelessWidget { final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; + final bool isSmallWebMode; + final bool enableGestures; late final BrowserTabBar _tabBar; late final _size = Size.fromHeight(_tabBar.getToolbarHeight()); @@ -61,12 +66,16 @@ class BrowserTopAppBar extends StatelessWidget { required this.showMainToolbar, required this.showContextualToolbar, required this.showQuickTabSwitcherBar, + required this.isSmallWebMode, + this.enableGestures = true, }) { _tabBar = BrowserTabBar( showMainToolbar: showMainToolbar, displayedSheet: null, showContextualToolbar: false, showQuickTabSwitcherBar: false, + isSmallWebMode: isSmallWebMode, + enableGestures: enableGestures, hideMainToolbarButtonsDuplicatedInContextualToolbar: showContextualToolbar, ); @@ -86,7 +95,9 @@ class BrowserBottomAppBar extends StatelessWidget { final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; + final bool isSmallWebMode; final Sheet? displayedSheet; + final bool enableGestures; late final BrowserTabBar _tabBar; late final _size = Size.fromHeight(_tabBar.getToolbarHeight()); @@ -97,12 +108,16 @@ class BrowserBottomAppBar extends StatelessWidget { required this.displayedSheet, required this.showContextualToolbar, required this.showQuickTabSwitcherBar, + required this.isSmallWebMode, + this.enableGestures = true, }) { _tabBar = BrowserTabBar( displayedSheet: displayedSheet, showMainToolbar: showMainToolbar, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: showQuickTabSwitcherBar, + isSmallWebMode: isSmallWebMode, + enableGestures: enableGestures, hideMainToolbarButtonsDuplicatedInContextualToolbar: showContextualToolbar, ); @@ -133,6 +148,8 @@ class BrowserTabBar extends HookConsumerWidget { final bool showQuickTabSwitcherBar; final Sheet? displayedSheet; final bool hideMainToolbarButtonsDuplicatedInContextualToolbar; + final bool isSmallWebMode; + final bool enableGestures; const BrowserTabBar({ super.key, @@ -140,6 +157,8 @@ class BrowserTabBar extends HookConsumerWidget { required this.displayedSheet, required this.showContextualToolbar, required this.showQuickTabSwitcherBar, + required this.isSmallWebMode, + required this.enableGestures, this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false, }); @@ -195,8 +214,9 @@ class BrowserTabBar extends HookConsumerWidget { c.buttonId == ToolbarButtonId.navigationMenu.name && c.isVisible, ); - final showMainToolbarTabsCount = !tabsCountInContextual; - final showMainToolbarNavigationButton = !menuInContextual; + final showMainToolbarTabsCount = !isSmallWebMode && !tabsCountInContextual; + final showMainToolbarNavigationButton = + !isSmallWebMode && !menuInContextual; final containerColor = ref.watch( watchTabContainerDataProvider( @@ -232,6 +252,19 @@ class BrowserTabBar extends HookConsumerWidget { : const AppBarTitle() : null, actions: [ + if (isSmallWebMode) + ReaderButton( + buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton( + onTap: isLoading + ? null + : () async { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(!readerActive); + }, + child: icon, + ), + ), if (showMainToolbarTabsCount) TabsCountButton( selectedTabId: selectedTabId, @@ -248,61 +281,76 @@ class BrowserTabBar extends HookConsumerWidget { selectedTabId: selectedTabId, displayedSheet: displayedSheet, ), - onHorizontalDragStart: (details) { - dragStartPosition.value = details.globalPosition; - }, - onHorizontalDragEnd: (details) async { - final distance = dragStartPosition.value - details.globalPosition; + onHorizontalDragStart: !enableGestures + ? null + : (details) { + dragStartPosition.value = details.globalPosition; + }, + onHorizontalDragEnd: !enableGestures + ? null + : (details) async { + final distance = dragStartPosition.value - details.globalPosition; - if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { - final selectedTab = ref.read(selectedTabProvider); - final setting = await ref - .read(generalSettingsRepositoryProvider.notifier) - .fetchSettings(); + if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { + final selectedTab = ref.read(selectedTabProvider); + final setting = await ref + .read(generalSettingsRepositoryProvider.notifier) + .fetchSettings(); - if (selectedTab != null) { - switch (setting.tabBarSwipeAction) { - case TabBarSwipeAction.switchLastOpened: - await ref - .read(tabRepositoryProvider.notifier) - .selectPreviouslyOpenedTab(selectedTab); - case TabBarSwipeAction.navigateOrderedTabs: - if (distance.dx < 0) { - await ref - .read(tabRepositoryProvider.notifier) - .selectPreviousTab(selectedTab); - } else { - await ref - .read(tabRepositoryProvider.notifier) - .selectNextTab(selectedTab); + if (selectedTab != null) { + switch (setting.tabBarSwipeAction) { + case TabBarSwipeAction.switchLastOpened: + await ref + .read(tabRepositoryProvider.notifier) + .selectPreviouslyOpenedTab(selectedTab); + case TabBarSwipeAction.navigateOrderedTabs: + if (distance.dx < 0) { + await ref + .read(tabRepositoryProvider.notifier) + .selectPreviousTab(selectedTab); + } else { + await ref + .read(tabRepositoryProvider.notifier) + .selectNextTab(selectedTab); + } + } } - } - } - } - }, - onVerticalDragStart: (details) { - dragStartPosition.value = details.globalPosition; - }, - onVerticalDragEnd: (details) { - final distance = dragStartPosition.value - details.globalPosition; + } + }, + onVerticalDragStart: !enableGestures + ? null + : (details) { + dragStartPosition.value = details.globalPosition; + }, + onVerticalDragEnd: !enableGestures + ? null + : (details) { + final distance = dragStartPosition.value - details.globalPosition; - // Swipe direction for dismiss depends on toolbar position: - // - Bottom bar: swipe down to dismiss (positive distance.dy) - // - Top bar: swipe up to dismiss (negative distance.dy) - const dismissThreshold = kToolbarHeight * 0.5; - final shouldDismiss = switch (tabBarPosition) { - TabBarPosition.bottom => - distance.dy.isNegative && distance.dy.abs() > dismissThreshold, - TabBarPosition.top => - !distance.dy.isNegative && distance.dy.abs() > dismissThreshold, - }; - if (shouldDismiss && ref.read(bottomSheetControllerProvider) == null) { - unawaited(HapticFeedback.lightImpact()); - ref - .read(toolbarVisibilityControllerProvider(selectedTabId).notifier) - .dismiss(); - } - }, + // Swipe direction for dismiss depends on toolbar position: + // - Bottom bar: swipe down to dismiss (positive distance.dy) + // - Top bar: swipe up to dismiss (negative distance.dy) + const dismissThreshold = kToolbarHeight * 0.5; + final shouldDismiss = switch (tabBarPosition) { + TabBarPosition.bottom => + distance.dy.isNegative && + distance.dy.abs() > dismissThreshold, + TabBarPosition.top => + !distance.dy.isNegative && + distance.dy.abs() > dismissThreshold, + }; + if (shouldDismiss && + ref.read(bottomSheetControllerProvider) == null) { + unawaited(HapticFeedback.lightImpact()); + ref + .read( + toolbarVisibilityControllerProvider( + selectedTabId, + ).notifier, + ) + .dismiss(); + } + }, ); } } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 9dd0ab57..ba07711d 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -159,11 +159,13 @@ class _BrowserViewState extends ConsumerState data: (androidInfo) { if (androidInfo == null) { // Not Android, always show GeckoView based on route - return topRoute is GoRoute && topRoute.name == BrowserRoute.name; + return topRoute is GoRoute && + topRoute.name == BrowserRoute.name; } // Android: only apply visibility fix on Android 12 and lower (API <= 31) if (androidInfo.sdkInt <= 31) { - return topRoute is GoRoute && topRoute.name == BrowserRoute.name; + return topRoute is GoRoute && + topRoute.name == BrowserRoute.name; } // Android 13+: always show GeckoView return true; diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart b/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart index b61cd9c7..331394c7 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; +import 'package:weblibre/presentation/widgets/sliding_pill_toggle.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; @@ -172,86 +173,16 @@ class _TabbedBangSelector extends HookConsumerWidget { return () => tabController.removeListener(listener); }, [tabController]); - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final disableAnimations = MediaQuery.disableAnimationsOf(context); - return Column( mainAxisSize: MainAxisSize.min, children: [ // Animated sliding pill toggle Padding( padding: const EdgeInsets.only(right: 12.0), - child: Container( - height: 36, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(18), - ), - child: Stack( - children: [ - AnimatedAlign( - alignment: tabIndex.value == 1 - ? Alignment.centerRight - : Alignment.centerLeft, - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - child: FractionallySizedBox( - widthFactor: 0.5, - child: Container( - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(18), - ), - ), - ), - ), - Row( - children: [ - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => tabController.animateTo(0), - child: Center( - child: Text( - 'Search On This Site', - style: theme.textTheme.labelMedium?.copyWith( - color: tabIndex.value == 0 - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: tabIndex.value == 0 - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - ), - ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => tabController.animateTo(1), - child: Center( - child: Text( - 'All Providers', - style: theme.textTheme.labelMedium?.copyWith( - color: tabIndex.value == 1 - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: tabIndex.value == 1 - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - ), - ), - ], - ), - ], - ), + child: SlidingPillToggle( + selectedIndex: tabIndex.value, + labels: const ['Search On This Site', 'All Providers'], + onChanged: (index) => tabController.animateTo(index), ), ), // Tab content diff --git a/app/lib/features/geckoview/features/tabs/data/providers.g.dart b/app/lib/features/geckoview/features/tabs/data/providers.g.dart index 3628962d..91b90dbb 100644 --- a/app/lib/features/geckoview/features/tabs/data/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/providers.g.dart @@ -48,4 +48,4 @@ final class TabDatabaseProvider } } -String _$tabDatabaseHash() => r'c40af8d5e17f61abcf14ea096ace3d0c2fb3d8d8'; +String _$tabDatabaseHash() => r'62466f063f5eae2a32c37dce0496b8e47e7cb953'; diff --git a/app/lib/features/geckoview/features/top_sites/data/providers.g.dart b/app/lib/features/geckoview/features/top_sites/data/providers.g.dart index 73331a3c..72a9c0ff 100644 --- a/app/lib/features/geckoview/features/top_sites/data/providers.g.dart +++ b/app/lib/features/geckoview/features/top_sites/data/providers.g.dart @@ -49,4 +49,4 @@ final class TopSiteDatabaseProvider } } -String _$topSiteDatabaseHash() => r'bee041ef1b144ce63c38080b2534e4109399fa6f'; +String _$topSiteDatabaseHash() => r'a5afe9a807174ae3382931612b25f959efccdded'; diff --git a/app/lib/features/quotes/data/providers.g.dart b/app/lib/features/quotes/data/providers.g.dart index c5dc7ccf..a5621fa2 100644 --- a/app/lib/features/quotes/data/providers.g.dart +++ b/app/lib/features/quotes/data/providers.g.dart @@ -48,4 +48,4 @@ final class QuotesDatabaseProvider } } -String _$quotesDatabaseHash() => r'be623fb69eb03f4c264d1a4fd07300fa669ecc86'; +String _$quotesDatabaseHash() => r'14491e3de1a188138b329776e1f4d83101b2ba13'; diff --git a/app/lib/features/settings/presentation/screens/browsing_settings.dart b/app/lib/features/settings/presentation/screens/browsing_settings.dart index 7d9198a4..9e57fdce 100644 --- a/app/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/app/lib/features/settings/presentation/screens/browsing_settings.dart @@ -67,6 +67,7 @@ class _TabsSection extends StatelessWidget { children: [ SettingSection(name: 'Tabs'), _NewTabDefaultSection(), + _SmallWebTabDefaultSection(), _NewTabPositionSection(), _ShowContainerUiTile(), _ShowIsolatedTabUiTile(), @@ -188,6 +189,85 @@ class _NewTabDefaultSection extends HookConsumerWidget { } } +class _SmallWebTabDefaultSection extends HookConsumerWidget { + const _SmallWebTabDefaultSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appColors = AppColors.of(context); + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final smallWebTabType = settings.smallWebTabType; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Small Web Tab Default'), + subtitle: Text('Choose the tab type used when entering Small Web'), + leading: Icon(Icons.explore), + contentPadding: EdgeInsets.zero, + ), + Center( + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + const ButtonSegment( + value: TabType.regular, + label: Text('Regular'), + icon: Icon(MdiIcons.tab), + ), + ButtonSegment( + value: TabType.private, + label: const Text('Private'), + icon: Icon( + MdiIcons.dominoMask, + color: smallWebTabType == TabType.private + ? null + : appColors.privateTabPurple, + ), + ), + if (settings.showIsolatedTabUi) + ButtonSegment( + value: TabType.isolated, + label: const Text('Isolated'), + icon: Icon( + MdiIcons.snowflake, + color: smallWebTabType == TabType.isolated + ? null + : appColors.isolatedTabTeal, + ), + ), + ], + selected: {smallWebTabType}, + onSelectionChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.smallWebTabType(value.first), + ); + }, + style: switch (smallWebTabType) { + TabType.regular => null, + TabType.private => SegmentedButton.styleFrom( + selectedBackgroundColor: appColors.privateSelectionOverlay, + ), + TabType.child => null, + TabType.isolated => SegmentedButton.styleFrom( + selectedBackgroundColor: appColors.isolatedSelectionOverlay, + ), + }, + ), + ), + ], + ), + ); + } +} + class _ExternalLinkHandlingSection extends HookConsumerWidget { const _ExternalLinkHandlingSection(); diff --git a/app/lib/features/small_web/data/database/daos/small_web_item_dao.dart b/app/lib/features/small_web/data/database/daos/small_web_item_dao.dart new file mode 100644 index 00000000..904baaa2 --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/small_web_item_dao.dart @@ -0,0 +1,74 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/small_web/data/database/daos/small_web_item_dao.drift.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; + +@DriftAccessor() +class SmallWebItemDao extends DatabaseAccessor + with $SmallWebItemDaoMixin { + SmallWebItemDao(super.attachedDatabase); + + SingleOrNullSelectable getLatestFetchedAt( + SmallWebSourceKind sourceKind, + KagiSmallWebMode? mode, + ) { + final query = selectOnly(db.smallWebMemberships) + ..addColumns([db.smallWebMemberships.fetchedAt]) + ..where( + db.smallWebMemberships.sourceKind.equalsValue(sourceKind) & + (mode == null + ? db.smallWebMemberships.mode.isNull() + : db.smallWebMemberships.mode.equals(mode.name)), + ) + ..orderBy([ + OrderingTerm( + expression: db.smallWebMemberships.fetchedAt, + mode: OrderingMode.desc, + ), + ]) + ..limit(1); + + return query.map((row) => row.read(db.smallWebMemberships.fetchedAt)); + } + + Selectable getDiscoverableKagiItems( + KagiSmallWebMode mode, + String? category, + ) { + return db.definitionsDrift.getDiscoverableKagiItems( + sourceKind: SmallWebSourceKind.kagi, + mode: mode.name, + category: category, + ); + } + + Future updateTitle(String id, String title) { + return (db.smallWebItems.update()..where((i) => i.id.equals(id))).write( + SmallWebItemsCompanion( + title: Value(title), + updatedAt: Value(DateTime.now()), + ), + ); + } +} diff --git a/app/lib/features/small_web/data/database/daos/small_web_item_dao.drift.dart b/app/lib/features/small_web/data/database/daos/small_web_item_dao.drift.dart new file mode 100644 index 00000000..898303e7 --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/small_web_item_dao.drift.dart @@ -0,0 +1,13 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/small_web/data/database/database.dart' as i1; + +mixin $SmallWebItemDaoMixin on i0.DatabaseAccessor { + SmallWebItemDaoManager get managers => SmallWebItemDaoManager(this); +} + +class SmallWebItemDaoManager { + final $SmallWebItemDaoMixin _db; + SmallWebItemDaoManager(this._db); +} diff --git a/app/lib/features/small_web/data/database/daos/small_web_visit_dao.dart b/app/lib/features/small_web/data/database/daos/small_web_visit_dao.dart new file mode 100644 index 00000000..97710b6b --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/small_web_visit_dao.dart @@ -0,0 +1,81 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/small_web/data/database/daos/small_web_visit_dao.drift.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; + +@DriftAccessor() +class SmallWebVisitDao extends DatabaseAccessor + with $SmallWebVisitDaoMixin { + SmallWebVisitDao(super.attachedDatabase); + + Future insertVisit(SmallWebVisit visit) { + return db.smallWebVisits.insertOne(visit); + } + + Selectable getRecentVisits({ + required SmallWebSourceKind sourceKind, + required KagiSmallWebMode? mode, + int limit = 50, + }) { + return db.definitionsDrift.getRecentVisits( + sourceKind: sourceKind, + mode: mode?.name, + limit: limit, + ); + } + + Selectable getRecentItemIds({ + required SmallWebSourceKind sourceKind, + required KagiSmallWebMode? mode, + int limit = 20, + }) { + return db.definitionsDrift.getRecentVisitItemIds( + sourceKind: sourceKind, + mode: mode?.name, + limit: limit, + ); + } + + Future deleteVisitById(String visitId) { + return (db.delete( + db.smallWebVisits, + )..where((t) => t.id.equals(visitId))).go(); + } + + Future deleteVisitsBySourceAndMode({ + required SmallWebSourceKind sourceKind, + required KagiSmallWebMode? mode, + }) { + return (db.delete(db.smallWebVisits)..where( + (t) => + t.sourceKind.equalsValue(sourceKind) & + (mode == null ? t.mode.isNull() : t.mode.equals(mode.name)), + )) + .go(); + } + + Future deleteAllVisits() { + return db.delete(db.smallWebVisits).go(); + } +} diff --git a/app/lib/features/small_web/data/database/daos/small_web_visit_dao.drift.dart b/app/lib/features/small_web/data/database/daos/small_web_visit_dao.drift.dart new file mode 100644 index 00000000..c7b0946e --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/small_web_visit_dao.drift.dart @@ -0,0 +1,13 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/small_web/data/database/database.dart' as i1; + +mixin $SmallWebVisitDaoMixin on i0.DatabaseAccessor { + SmallWebVisitDaoManager get managers => SmallWebVisitDaoManager(this); +} + +class SmallWebVisitDaoManager { + final $SmallWebVisitDaoMixin _db; + SmallWebVisitDaoManager(this._db); +} diff --git a/app/lib/features/small_web/data/database/daos/wander_console_dao.dart b/app/lib/features/small_web/data/database/daos/wander_console_dao.dart new file mode 100644 index 00000000..7faf3ad8 --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/wander_console_dao.dart @@ -0,0 +1,58 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/small_web/data/database/daos/wander_console_dao.drift.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; + +@DriftAccessor() +class WanderConsoleDao extends DatabaseAccessor + with $WanderConsoleDaoMixin { + WanderConsoleDao(super.attachedDatabase); + + Future upsertConsole(WanderConsole console) { + return db.wanderConsoles.insertOne( + console, + onConflict: DoUpdate( + (old) => WanderConsolesCompanion( + lastFetchedAt: Value(console.lastFetchedAt), + lastFetchFailed: Value(console.lastFetchFailed), + ), + target: [db.wanderConsoles.url], + ), + ); + } + + SingleOrNullSelectable getConsole(Uri url) { + return (db.wanderConsoles.select()..where((c) => c.url.equalsValue(url))); + } + + Selectable getExistingConsoleUrls(List urls) { + final query = selectOnly(db.wanderConsoles) + ..addColumns([db.wanderConsoles.url]) + ..where(db.wanderConsoles.url.isInValues(urls)); + + return query.map((row) => row.readWithConverter(db.wanderConsoles.url)!); + } + + Selectable getDiscoveredConsoleUrls({int limit = 1000}) { + return db.definitionsDrift.getDiscoveredConsoleUrls(limit: limit); + } +} diff --git a/app/lib/features/small_web/data/database/daos/wander_console_dao.drift.dart b/app/lib/features/small_web/data/database/daos/wander_console_dao.drift.dart new file mode 100644 index 00000000..0eb4dbb9 --- /dev/null +++ b/app/lib/features/small_web/data/database/daos/wander_console_dao.drift.dart @@ -0,0 +1,13 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/small_web/data/database/database.dart' as i1; + +mixin $WanderConsoleDaoMixin on i0.DatabaseAccessor { + WanderConsoleDaoManager get managers => WanderConsoleDaoManager(this); +} + +class WanderConsoleDaoManager { + final $WanderConsoleDaoMixin _db; + WanderConsoleDaoManager(this._db); +} diff --git a/app/lib/features/small_web/data/database/database.dart b/app/lib/features/small_web/data/database/database.dart new file mode 100644 index 00000000..53679366 --- /dev/null +++ b/app/lib/features/small_web/data/database/database.dart @@ -0,0 +1,47 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:weblibre/features/small_web/data/database/daos/small_web_item_dao.dart'; +import 'package:weblibre/features/small_web/data/database/daos/small_web_visit_dao.dart'; +import 'package:weblibre/features/small_web/data/database/daos/wander_console_dao.dart'; +import 'package:weblibre/features/small_web/data/database/database.drift.dart'; + +@DriftDatabase( + include: {'definitions.drift'}, + daos: [SmallWebItemDao, SmallWebVisitDao, WanderConsoleDao], +) +class SmallWebDatabase extends $SmallWebDatabase { + SmallWebDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); +} diff --git a/app/lib/features/small_web/data/database/database.drift.dart b/app/lib/features/small_web/data/database/database.drift.dart new file mode 100644 index 00000000..83635529 --- /dev/null +++ b/app/lib/features/small_web/data/database/database.drift.dart @@ -0,0 +1,148 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart' + as i1; +import 'package:weblibre/features/small_web/data/database/daos/small_web_item_dao.dart' + as i2; +import 'package:weblibre/features/small_web/data/database/database.dart' as i3; +import 'package:weblibre/features/small_web/data/database/daos/small_web_visit_dao.dart' + as i4; +import 'package:weblibre/features/small_web/data/database/daos/wander_console_dao.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; +import 'package:sqlite3/common.dart' as i7; + +abstract class $SmallWebDatabase extends i0.GeneratedDatabase { + $SmallWebDatabase(i0.QueryExecutor e) : super(e); + $SmallWebDatabaseManager get managers => $SmallWebDatabaseManager(this); + late final i1.SmallWebItems smallWebItems = i1.SmallWebItems(this); + late final i1.SmallWebMemberships smallWebMemberships = + i1.SmallWebMemberships(this); + late final i1.WanderConsoles wanderConsoles = i1.WanderConsoles(this); + late final i1.WanderConsoleNeighbors wanderConsoleNeighbors = + i1.WanderConsoleNeighbors(this); + late final i1.SmallWebVisits smallWebVisits = i1.SmallWebVisits(this); + late final i2.SmallWebItemDao smallWebItemDao = i2.SmallWebItemDao( + this as i3.SmallWebDatabase, + ); + late final i4.SmallWebVisitDao smallWebVisitDao = i4.SmallWebVisitDao( + this as i3.SmallWebDatabase, + ); + late final i5.WanderConsoleDao wanderConsoleDao = i5.WanderConsoleDao( + this as i3.SmallWebDatabase, + ); + i1.DefinitionsDrift get definitionsDrift => i6.ReadDatabaseContainer( + this, + ).accessor(i1.DefinitionsDrift.new); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + smallWebItems, + smallWebMemberships, + i1.idxMembershipSourceMode, + i1.idxMembershipItem, + wanderConsoles, + wanderConsoleNeighbors, + smallWebVisits, + i1.idxVisitMode, + i1.idxVisitItem, + ]; + @override + i0.StreamQueryUpdateRules get streamUpdateRules => + const i0.StreamQueryUpdateRules([ + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'small_web_items', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [ + i0.TableUpdate('small_web_memberships', kind: i0.UpdateKind.delete), + ], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'wander_consoles', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [ + i0.TableUpdate( + 'wander_console_neighbors', + kind: i0.UpdateKind.delete, + ), + ], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'small_web_items', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [ + i0.TableUpdate('small_web_visits', kind: i0.UpdateKind.delete), + ], + ), + ]); +} + +class $SmallWebDatabaseManager { + final $SmallWebDatabase _db; + $SmallWebDatabaseManager(this._db); + i1.$SmallWebItemsTableManager get smallWebItems => + i1.$SmallWebItemsTableManager(_db, _db.smallWebItems); + i1.$SmallWebMembershipsTableManager get smallWebMemberships => + i1.$SmallWebMembershipsTableManager(_db, _db.smallWebMemberships); + i1.$WanderConsolesTableManager get wanderConsoles => + i1.$WanderConsolesTableManager(_db, _db.wanderConsoles); + i1.$WanderConsoleNeighborsTableManager get wanderConsoleNeighbors => + i1.$WanderConsoleNeighborsTableManager(_db, _db.wanderConsoleNeighbors); + i1.$SmallWebVisitsTableManager get smallWebVisits => + i1.$SmallWebVisitsTableManager(_db, _db.smallWebVisits); +} + +extension DefineFunctions on i7.CommonDatabase { + void defineFunctions({ + required String Function(int, String?) lexoRankNext, + required String Function(int, String?) lexoRankPrevious, + required String Function(String?, String?) lexoRankReorderAfter, + required String Function(String?, String?) lexoRankReorderBefore, + }) { + createFunction( + functionName: 'lexo_rank_next', + argumentCount: const i7.AllowedArgumentCount(2), + function: (args) { + final arg0 = args[0] as int; + final arg1 = args[1] as String?; + return lexoRankNext(arg0, arg1); + }, + ); + createFunction( + functionName: 'lexo_rank_previous', + argumentCount: const i7.AllowedArgumentCount(2), + function: (args) { + final arg0 = args[0] as int; + final arg1 = args[1] as String?; + return lexoRankPrevious(arg0, arg1); + }, + ); + createFunction( + functionName: 'lexo_rank_reorder_after', + argumentCount: const i7.AllowedArgumentCount(2), + function: (args) { + final arg0 = args[0] as String?; + final arg1 = args[1] as String?; + return lexoRankReorderAfter(arg0, arg1); + }, + ); + createFunction( + functionName: 'lexo_rank_reorder_before', + argumentCount: const i7.AllowedArgumentCount(2), + function: (args) { + final arg0 = args[0] as String?; + final arg1 = args[1] as String?; + return lexoRankReorderBefore(arg0, arg1); + }, + ); + } +} diff --git a/app/lib/features/small_web/data/database/definitions.drift b/app/lib/features/small_web/data/database/definitions.drift new file mode 100644 index 00000000..5f78923e --- /dev/null +++ b/app/lib/features/small_web/data/database/definitions.drift @@ -0,0 +1,124 @@ +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/data/database/converters/string_list.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/models/wander_console_source.dart'; + +CREATE TABLE small_web_items ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE MAPPED BY `const UriConverter()`, + title TEXT, + domain TEXT NOT NULL, + author TEXT, + summary TEXT, + published_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) AS SmallWebItem; + +CREATE TABLE small_web_memberships ( + id TEXT NOT NULL PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES small_web_items(id) ON DELETE CASCADE, + source_kind ENUM(SmallWebSourceKind) NOT NULL, + mode TEXT, + console_url TEXT MAPPED BY `const UriConverterNullable()`, + categories TEXT NOT NULL DEFAULT '[]' MAPPED BY `const StringListConverter()`, + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(item_id, source_kind, mode, console_url) +) AS SmallWebMembership; + +CREATE INDEX idx_membership_source_mode ON small_web_memberships (source_kind, mode); +CREATE INDEX idx_membership_item ON small_web_memberships (item_id); + +CREATE TABLE wander_consoles ( + url TEXT NOT NULL PRIMARY KEY MAPPED BY `const UriConverter()`, + wander_js_url TEXT NOT NULL MAPPED BY `const UriConverter()`, + last_fetched_at DATETIME, + last_fetch_failed BOOL, + discovered_from_url TEXT MAPPED BY `const UriConverterNullable()`, + source ENUM(WanderConsoleSource) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) AS WanderConsole; + +CREATE TABLE wander_console_neighbors ( + source_console_url TEXT NOT NULL REFERENCES wander_consoles(url) ON DELETE CASCADE, + target_console_url TEXT NOT NULL, + discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (source_console_url, target_console_url) +) AS WanderConsoleNeighbor; + +CREATE TABLE small_web_visits ( + id TEXT NOT NULL PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES small_web_items(id) ON DELETE CASCADE, + source_kind ENUM(SmallWebSourceKind) NOT NULL, + mode TEXT, + console_url TEXT MAPPED BY `const UriConverterNullable()`, + visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) AS SmallWebVisit; + +CREATE INDEX idx_visit_mode ON small_web_visits (source_kind, mode, visited_at); +CREATE INDEX idx_visit_item ON small_web_visits (item_id); + +getDiscoverableKagiItems: + SELECT i.* FROM small_web_items i + INNER JOIN small_web_memberships m ON m.item_id = i.id + WHERE m.source_kind = :sourceKind + AND m.mode = :mode + AND (:category IS NULL OR EXISTS ( + SELECT 1 FROM json_each(m.categories) WHERE value = :category + )) + AND i.id NOT IN ( + SELECT v.item_id FROM small_web_visits v + WHERE v.source_kind = :sourceKind AND v.mode = :mode + ORDER BY v.visited_at DESC LIMIT 20 + ); + +getRecentVisits: + SELECT v.*, i.url, i.title, i.domain + FROM small_web_visits v + INNER JOIN small_web_items i ON i.id = v.item_id + WHERE v.source_kind = :sourceKind + AND ((:mode IS NULL AND v.mode IS NULL) OR v.mode = :mode) + ORDER BY v.visited_at DESC + LIMIT :limit; + +getDiscoveredConsoleUrls: + SELECT url FROM wander_consoles ORDER BY created_at DESC LIMIT :limit; + +getWanderPagesForConsole: + SELECT i.* FROM small_web_items i + INNER JOIN small_web_memberships m ON m.item_id = i.id + WHERE m.source_kind = :sourceKind AND m.console_url = CAST(:consoleUrl AS TEXT); + +getConsoleNeighborCount: + SELECT COUNT(*) AS c FROM wander_console_neighbors WHERE source_console_url = :consoleUrl; + +getAllModeItemCounts: + SELECT source_kind, mode, COUNT(*) AS c + FROM small_web_memberships + GROUP BY source_kind, mode; + +getRecentVisitItemIds: + SELECT DISTINCT item_id FROM small_web_visits + WHERE source_kind = :sourceKind + AND ((:mode IS NULL AND mode IS NULL) OR mode = :mode) + ORDER BY visited_at DESC + LIMIT :limit; + +getNeighborConsolesWithPageCounts: + SELECT wc.url, wc.last_fetched_at, wc.last_fetch_failed, + (SELECT COUNT(*) FROM small_web_memberships m + WHERE m.source_kind = :sourceKind AND m.console_url = CAST(wc.url AS TEXT)) AS page_count + FROM wander_console_neighbors n + INNER JOIN wander_consoles wc ON CAST(wc.url AS TEXT) = n.target_console_url + WHERE n.source_console_url = :sourceConsoleUrl + AND COALESCE(wc.last_fetch_failed, 0) = 0 + ORDER BY page_count DESC; + +getAllConsolesWithPageCounts: + SELECT wc.url, wc.last_fetched_at, wc.last_fetch_failed, + (SELECT COUNT(*) FROM small_web_memberships m + WHERE m.source_kind = :sourceKind AND m.console_url = CAST(wc.url AS TEXT)) AS page_count + FROM wander_consoles wc + WHERE COALESCE(wc.last_fetch_failed, 0) = 0 + AND (:query = '' OR CAST(wc.url AS TEXT) LIKE '%' || :query || '%') + ORDER BY page_count DESC; diff --git a/app/lib/features/small_web/data/database/definitions.drift.dart b/app/lib/features/small_web/data/database/definitions.drift.dart new file mode 100644 index 00000000..b2a7e437 --- /dev/null +++ b/app/lib/features/small_web/data/database/definitions.drift.dart @@ -0,0 +1,4001 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart' + as i1; +import 'package:weblibre/data/database/converters/uri.dart' as i2; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart' + as i3; +import 'package:weblibre/data/database/converters/string_list.dart' as i4; +import 'package:weblibre/features/small_web/data/models/wander_console_source.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $SmallWebItemsCreateCompanionBuilder = + i1.SmallWebItemsCompanion Function({ + required String id, + required Uri url, + i0.Value title, + required String domain, + i0.Value author, + i0.Value summary, + i0.Value publishedAt, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value rowid, + }); +typedef $SmallWebItemsUpdateCompanionBuilder = + i1.SmallWebItemsCompanion Function({ + i0.Value id, + i0.Value url, + i0.Value title, + i0.Value domain, + i0.Value author, + i0.Value summary, + i0.Value publishedAt, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value rowid, + }); + +final class $SmallWebItemsReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.SmallWebItems, + i1.SmallWebItem + > { + $SmallWebItemsReferences(super.$_db, super.$_table, super.$_typedResult); + + static i0.MultiTypedResultKey< + i1.SmallWebMemberships, + List + > + _smallWebMembershipsRefsTable(i0.GeneratedDatabase db) => + i0.MultiTypedResultKey.fromTable( + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_memberships'), + aliasName: i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_items').id, + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_memberships').itemId, + ), + ); + + i1.$SmallWebMembershipsProcessedTableManager get smallWebMembershipsRefs { + final manager = i1 + .$SmallWebMembershipsTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('small_web_memberships'), + ) + .filter((f) => f.itemId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _smallWebMembershipsRefsTable($_db), + ); + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static i0.MultiTypedResultKey> + _smallWebVisitsRefsTable(i0.GeneratedDatabase db) => + i0.MultiTypedResultKey.fromTable( + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_visits'), + aliasName: i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_items').id, + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_visits').itemId, + ), + ); + + i1.$SmallWebVisitsProcessedTableManager get smallWebVisitsRefs { + final manager = i1 + .$SmallWebVisitsTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('small_web_visits'), + ) + .filter((f) => f.itemId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_smallWebVisitsRefsTable($_db)); + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $SmallWebItemsFilterComposer + extends i0.Composer { + $SmallWebItemsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get author => $composableBuilder( + column: $table.author, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get publishedAt => $composableBuilder( + column: $table.publishedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.Expression smallWebMembershipsRefs( + i0.Expression Function(i1.$SmallWebMembershipsFilterComposer f) f, + ) { + final i1.$SmallWebMembershipsFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_memberships'), + getReferencedColumn: (t) => t.itemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebMembershipsFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_memberships'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + i0.Expression smallWebVisitsRefs( + i0.Expression Function(i1.$SmallWebVisitsFilterComposer f) f, + ) { + final i1.$SmallWebVisitsFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_visits'), + getReferencedColumn: (t) => t.itemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebVisitsFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_visits'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $SmallWebItemsOrderingComposer + extends i0.Composer { + $SmallWebItemsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get author => $composableBuilder( + column: $table.author, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get publishedAt => $composableBuilder( + column: $table.publishedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $SmallWebItemsAnnotationComposer + extends i0.Composer { + $SmallWebItemsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + i0.GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + i0.GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + i0.GeneratedColumn get author => + $composableBuilder(column: $table.author, builder: (column) => column); + + i0.GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + i0.GeneratedColumn get publishedAt => $composableBuilder( + column: $table.publishedAt, + builder: (column) => column, + ); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.Expression smallWebMembershipsRefs( + i0.Expression Function(i1.$SmallWebMembershipsAnnotationComposer a) f, + ) { + final i1.$SmallWebMembershipsAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_memberships'), + getReferencedColumn: (t) => t.itemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebMembershipsAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_memberships'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + i0.Expression smallWebVisitsRefs( + i0.Expression Function(i1.$SmallWebVisitsAnnotationComposer a) f, + ) { + final i1.$SmallWebVisitsAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_visits'), + getReferencedColumn: (t) => t.itemId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebVisitsAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_visits'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $SmallWebItemsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.SmallWebItems, + i1.SmallWebItem, + i1.$SmallWebItemsFilterComposer, + i1.$SmallWebItemsOrderingComposer, + i1.$SmallWebItemsAnnotationComposer, + $SmallWebItemsCreateCompanionBuilder, + $SmallWebItemsUpdateCompanionBuilder, + (i1.SmallWebItem, i1.$SmallWebItemsReferences), + i1.SmallWebItem, + i0.PrefetchHooks Function({ + bool smallWebMembershipsRefs, + bool smallWebVisitsRefs, + }) + > { + $SmallWebItemsTableManager(i0.GeneratedDatabase db, i1.SmallWebItems table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$SmallWebItemsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$SmallWebItemsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$SmallWebItemsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value url = const i0.Value.absent(), + i0.Value title = const i0.Value.absent(), + i0.Value domain = const i0.Value.absent(), + i0.Value author = const i0.Value.absent(), + i0.Value summary = const i0.Value.absent(), + i0.Value publishedAt = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebItemsCompanion( + id: id, + url: url, + title: title, + domain: domain, + author: author, + summary: summary, + publishedAt: publishedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required Uri url, + i0.Value title = const i0.Value.absent(), + required String domain, + i0.Value author = const i0.Value.absent(), + i0.Value summary = const i0.Value.absent(), + i0.Value publishedAt = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebItemsCompanion.insert( + id: id, + url: url, + title: title, + domain: domain, + author: author, + summary: summary, + publishedAt: publishedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$SmallWebItemsReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({smallWebMembershipsRefs = false, smallWebVisitsRefs = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (smallWebMembershipsRefs) + i6.ReadDatabaseContainer( + db, + ).resultSet( + 'small_web_memberships', + ), + if (smallWebVisitsRefs) + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_visits'), + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (smallWebMembershipsRefs) + await i0.$_getPrefetchedData< + i1.SmallWebItem, + i1.SmallWebItems, + i1.SmallWebMembership + >( + currentTable: table, + referencedTable: i1.$SmallWebItemsReferences + ._smallWebMembershipsRefsTable(db), + managerFromTypedResult: (p0) => i1 + .$SmallWebItemsReferences(db, table, p0) + .smallWebMembershipsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.itemId == item.id, + ), + typedResults: items, + ), + if (smallWebVisitsRefs) + await i0.$_getPrefetchedData< + i1.SmallWebItem, + i1.SmallWebItems, + i1.SmallWebVisit + >( + currentTable: table, + referencedTable: i1.$SmallWebItemsReferences + ._smallWebVisitsRefsTable(db), + managerFromTypedResult: (p0) => i1 + .$SmallWebItemsReferences(db, table, p0) + .smallWebVisitsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.itemId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $SmallWebItemsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.SmallWebItems, + i1.SmallWebItem, + i1.$SmallWebItemsFilterComposer, + i1.$SmallWebItemsOrderingComposer, + i1.$SmallWebItemsAnnotationComposer, + $SmallWebItemsCreateCompanionBuilder, + $SmallWebItemsUpdateCompanionBuilder, + (i1.SmallWebItem, i1.$SmallWebItemsReferences), + i1.SmallWebItem, + i0.PrefetchHooks Function({ + bool smallWebMembershipsRefs, + bool smallWebVisitsRefs, + }) + >; +typedef $SmallWebMembershipsCreateCompanionBuilder = + i1.SmallWebMembershipsCompanion Function({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + i0.Value mode, + i0.Value consoleUrl, + i0.Value> categories, + i0.Value fetchedAt, + i0.Value rowid, + }); +typedef $SmallWebMembershipsUpdateCompanionBuilder = + i1.SmallWebMembershipsCompanion Function({ + i0.Value id, + i0.Value itemId, + i0.Value sourceKind, + i0.Value mode, + i0.Value consoleUrl, + i0.Value> categories, + i0.Value fetchedAt, + i0.Value rowid, + }); + +final class $SmallWebMembershipsReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.SmallWebMemberships, + i1.SmallWebMembership + > { + $SmallWebMembershipsReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static i1.SmallWebItems _itemIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('small_web_items') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet('small_web_memberships') + .itemId, + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_items').id, + ), + ); + + i1.$SmallWebItemsProcessedTableManager get itemId { + final $_column = $_itemColumn('item_id')!; + + final manager = i1 + .$SmallWebItemsTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('small_web_items'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_itemIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $SmallWebMembershipsFilterComposer + extends i0.Composer { + $SmallWebMembershipsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + i3.SmallWebSourceKind, + i3.SmallWebSourceKind, + int + > + get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get mode => $composableBuilder( + column: $table.mode, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get consoleUrl => + $composableBuilder( + column: $table.consoleUrl, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters, List, String> + get categories => $composableBuilder( + column: $table.categories, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get fetchedAt => $composableBuilder( + column: $table.fetchedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i1.$SmallWebItemsFilterComposer get itemId { + final i1.$SmallWebItemsFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebMembershipsOrderingComposer + extends i0.Composer { + $SmallWebMembershipsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get mode => $composableBuilder( + column: $table.mode, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get consoleUrl => $composableBuilder( + column: $table.consoleUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get categories => $composableBuilder( + column: $table.categories, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get fetchedAt => $composableBuilder( + column: $table.fetchedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i1.$SmallWebItemsOrderingComposer get itemId { + final i1.$SmallWebItemsOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebMembershipsAnnotationComposer + extends i0.Composer { + $SmallWebMembershipsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter + get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => column, + ); + + i0.GeneratedColumn get mode => + $composableBuilder(column: $table.mode, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get consoleUrl => + $composableBuilder( + column: $table.consoleUrl, + builder: (column) => column, + ); + + i0.GeneratedColumnWithTypeConverter, String> get categories => + $composableBuilder( + column: $table.categories, + builder: (column) => column, + ); + + i0.GeneratedColumn get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => column); + + i1.$SmallWebItemsAnnotationComposer get itemId { + final i1.$SmallWebItemsAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebMembershipsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.SmallWebMemberships, + i1.SmallWebMembership, + i1.$SmallWebMembershipsFilterComposer, + i1.$SmallWebMembershipsOrderingComposer, + i1.$SmallWebMembershipsAnnotationComposer, + $SmallWebMembershipsCreateCompanionBuilder, + $SmallWebMembershipsUpdateCompanionBuilder, + (i1.SmallWebMembership, i1.$SmallWebMembershipsReferences), + i1.SmallWebMembership, + i0.PrefetchHooks Function({bool itemId}) + > { + $SmallWebMembershipsTableManager( + i0.GeneratedDatabase db, + i1.SmallWebMemberships table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$SmallWebMembershipsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$SmallWebMembershipsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$SmallWebMembershipsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value itemId = const i0.Value.absent(), + i0.Value sourceKind = + const i0.Value.absent(), + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + i0.Value> categories = const i0.Value.absent(), + i0.Value fetchedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebMembershipsCompanion( + id: id, + itemId: itemId, + sourceKind: sourceKind, + mode: mode, + consoleUrl: consoleUrl, + categories: categories, + fetchedAt: fetchedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + i0.Value> categories = const i0.Value.absent(), + i0.Value fetchedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebMembershipsCompanion.insert( + id: id, + itemId: itemId, + sourceKind: sourceKind, + mode: mode, + consoleUrl: consoleUrl, + categories: categories, + fetchedAt: fetchedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$SmallWebMembershipsReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({itemId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (itemId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.itemId, + referencedTable: i1 + .$SmallWebMembershipsReferences + ._itemIdTable(db), + referencedColumn: i1 + .$SmallWebMembershipsReferences + ._itemIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $SmallWebMembershipsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.SmallWebMemberships, + i1.SmallWebMembership, + i1.$SmallWebMembershipsFilterComposer, + i1.$SmallWebMembershipsOrderingComposer, + i1.$SmallWebMembershipsAnnotationComposer, + $SmallWebMembershipsCreateCompanionBuilder, + $SmallWebMembershipsUpdateCompanionBuilder, + (i1.SmallWebMembership, i1.$SmallWebMembershipsReferences), + i1.SmallWebMembership, + i0.PrefetchHooks Function({bool itemId}) + >; +typedef $WanderConsolesCreateCompanionBuilder = + i1.WanderConsolesCompanion Function({ + required Uri url, + required Uri wanderJsUrl, + i0.Value lastFetchedAt, + i0.Value lastFetchFailed, + i0.Value discoveredFromUrl, + required i5.WanderConsoleSource source, + i0.Value createdAt, + i0.Value rowid, + }); +typedef $WanderConsolesUpdateCompanionBuilder = + i1.WanderConsolesCompanion Function({ + i0.Value url, + i0.Value wanderJsUrl, + i0.Value lastFetchedAt, + i0.Value lastFetchFailed, + i0.Value discoveredFromUrl, + i0.Value source, + i0.Value createdAt, + i0.Value rowid, + }); + +class $WanderConsolesFilterComposer + extends i0.Composer { + $WanderConsolesFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get wanderJsUrl => + $composableBuilder( + column: $table.wanderJsUrl, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get lastFetchedAt => $composableBuilder( + column: $table.lastFetchedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get lastFetchFailed => $composableBuilder( + column: $table.lastFetchFailed, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get discoveredFromUrl => + $composableBuilder( + column: $table.discoveredFromUrl, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + i5.WanderConsoleSource, + i5.WanderConsoleSource, + int + > + get source => $composableBuilder( + column: $table.source, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $WanderConsolesOrderingComposer + extends i0.Composer { + $WanderConsolesOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get wanderJsUrl => $composableBuilder( + column: $table.wanderJsUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get lastFetchedAt => $composableBuilder( + column: $table.lastFetchedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get lastFetchFailed => $composableBuilder( + column: $table.lastFetchFailed, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get discoveredFromUrl => $composableBuilder( + column: $table.discoveredFromUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $WanderConsolesAnnotationComposer + extends i0.Composer { + $WanderConsolesAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get wanderJsUrl => + $composableBuilder( + column: $table.wanderJsUrl, + builder: (column) => column, + ); + + i0.GeneratedColumn get lastFetchedAt => $composableBuilder( + column: $table.lastFetchedAt, + builder: (column) => column, + ); + + i0.GeneratedColumn get lastFetchFailed => $composableBuilder( + column: $table.lastFetchFailed, + builder: (column) => column, + ); + + i0.GeneratedColumnWithTypeConverter get discoveredFromUrl => + $composableBuilder( + column: $table.discoveredFromUrl, + builder: (column) => column, + ); + + i0.GeneratedColumnWithTypeConverter get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $WanderConsolesTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.WanderConsoles, + i1.WanderConsole, + i1.$WanderConsolesFilterComposer, + i1.$WanderConsolesOrderingComposer, + i1.$WanderConsolesAnnotationComposer, + $WanderConsolesCreateCompanionBuilder, + $WanderConsolesUpdateCompanionBuilder, + ( + i1.WanderConsole, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.WanderConsoles, + i1.WanderConsole + >, + ), + i1.WanderConsole, + i0.PrefetchHooks Function() + > { + $WanderConsolesTableManager(i0.GeneratedDatabase db, i1.WanderConsoles table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$WanderConsolesFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$WanderConsolesOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$WanderConsolesAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value url = const i0.Value.absent(), + i0.Value wanderJsUrl = const i0.Value.absent(), + i0.Value lastFetchedAt = const i0.Value.absent(), + i0.Value lastFetchFailed = const i0.Value.absent(), + i0.Value discoveredFromUrl = const i0.Value.absent(), + i0.Value source = + const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.WanderConsolesCompanion( + url: url, + wanderJsUrl: wanderJsUrl, + lastFetchedAt: lastFetchedAt, + lastFetchFailed: lastFetchFailed, + discoveredFromUrl: discoveredFromUrl, + source: source, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required Uri url, + required Uri wanderJsUrl, + i0.Value lastFetchedAt = const i0.Value.absent(), + i0.Value lastFetchFailed = const i0.Value.absent(), + i0.Value discoveredFromUrl = const i0.Value.absent(), + required i5.WanderConsoleSource source, + i0.Value createdAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.WanderConsolesCompanion.insert( + url: url, + wanderJsUrl: wanderJsUrl, + lastFetchedAt: lastFetchedAt, + lastFetchFailed: lastFetchFailed, + discoveredFromUrl: discoveredFromUrl, + source: source, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $WanderConsolesProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.WanderConsoles, + i1.WanderConsole, + i1.$WanderConsolesFilterComposer, + i1.$WanderConsolesOrderingComposer, + i1.$WanderConsolesAnnotationComposer, + $WanderConsolesCreateCompanionBuilder, + $WanderConsolesUpdateCompanionBuilder, + ( + i1.WanderConsole, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.WanderConsoles, + i1.WanderConsole + >, + ), + i1.WanderConsole, + i0.PrefetchHooks Function() + >; +typedef $WanderConsoleNeighborsCreateCompanionBuilder = + i1.WanderConsoleNeighborsCompanion Function({ + required String sourceConsoleUrl, + required String targetConsoleUrl, + i0.Value discoveredAt, + i0.Value rowid, + }); +typedef $WanderConsoleNeighborsUpdateCompanionBuilder = + i1.WanderConsoleNeighborsCompanion Function({ + i0.Value sourceConsoleUrl, + i0.Value targetConsoleUrl, + i0.Value discoveredAt, + i0.Value rowid, + }); + +class $WanderConsoleNeighborsFilterComposer + extends i0.Composer { + $WanderConsoleNeighborsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get targetConsoleUrl => $composableBuilder( + column: $table.targetConsoleUrl, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get discoveredAt => $composableBuilder( + column: $table.discoveredAt, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $WanderConsoleNeighborsOrderingComposer + extends i0.Composer { + $WanderConsoleNeighborsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get targetConsoleUrl => $composableBuilder( + column: $table.targetConsoleUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get discoveredAt => $composableBuilder( + column: $table.discoveredAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $WanderConsoleNeighborsAnnotationComposer + extends i0.Composer { + $WanderConsoleNeighborsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get targetConsoleUrl => $composableBuilder( + column: $table.targetConsoleUrl, + builder: (column) => column, + ); + + i0.GeneratedColumn get discoveredAt => $composableBuilder( + column: $table.discoveredAt, + builder: (column) => column, + ); +} + +class $WanderConsoleNeighborsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.WanderConsoleNeighbors, + i1.WanderConsoleNeighbor, + i1.$WanderConsoleNeighborsFilterComposer, + i1.$WanderConsoleNeighborsOrderingComposer, + i1.$WanderConsoleNeighborsAnnotationComposer, + $WanderConsoleNeighborsCreateCompanionBuilder, + $WanderConsoleNeighborsUpdateCompanionBuilder, + ( + i1.WanderConsoleNeighbor, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.WanderConsoleNeighbors, + i1.WanderConsoleNeighbor + >, + ), + i1.WanderConsoleNeighbor, + i0.PrefetchHooks Function() + > { + $WanderConsoleNeighborsTableManager( + i0.GeneratedDatabase db, + i1.WanderConsoleNeighbors table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$WanderConsoleNeighborsFilterComposer($db: db, $table: table), + createOrderingComposer: () => i1 + .$WanderConsoleNeighborsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$WanderConsoleNeighborsAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + i0.Value sourceConsoleUrl = const i0.Value.absent(), + i0.Value targetConsoleUrl = const i0.Value.absent(), + i0.Value discoveredAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.WanderConsoleNeighborsCompanion( + sourceConsoleUrl: sourceConsoleUrl, + targetConsoleUrl: targetConsoleUrl, + discoveredAt: discoveredAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String sourceConsoleUrl, + required String targetConsoleUrl, + i0.Value discoveredAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.WanderConsoleNeighborsCompanion.insert( + sourceConsoleUrl: sourceConsoleUrl, + targetConsoleUrl: targetConsoleUrl, + discoveredAt: discoveredAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $WanderConsoleNeighborsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.WanderConsoleNeighbors, + i1.WanderConsoleNeighbor, + i1.$WanderConsoleNeighborsFilterComposer, + i1.$WanderConsoleNeighborsOrderingComposer, + i1.$WanderConsoleNeighborsAnnotationComposer, + $WanderConsoleNeighborsCreateCompanionBuilder, + $WanderConsoleNeighborsUpdateCompanionBuilder, + ( + i1.WanderConsoleNeighbor, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.WanderConsoleNeighbors, + i1.WanderConsoleNeighbor + >, + ), + i1.WanderConsoleNeighbor, + i0.PrefetchHooks Function() + >; +typedef $SmallWebVisitsCreateCompanionBuilder = + i1.SmallWebVisitsCompanion Function({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + i0.Value mode, + i0.Value consoleUrl, + i0.Value visitedAt, + i0.Value rowid, + }); +typedef $SmallWebVisitsUpdateCompanionBuilder = + i1.SmallWebVisitsCompanion Function({ + i0.Value id, + i0.Value itemId, + i0.Value sourceKind, + i0.Value mode, + i0.Value consoleUrl, + i0.Value visitedAt, + i0.Value rowid, + }); + +final class $SmallWebVisitsReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.SmallWebVisits, + i1.SmallWebVisit + > { + $SmallWebVisitsReferences(super.$_db, super.$_table, super.$_typedResult); + + static i1.SmallWebItems _itemIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('small_web_items') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_visits').itemId, + i6.ReadDatabaseContainer( + db, + ).resultSet('small_web_items').id, + ), + ); + + i1.$SmallWebItemsProcessedTableManager get itemId { + final $_column = $_itemColumn('item_id')!; + + final manager = i1 + .$SmallWebItemsTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('small_web_items'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_itemIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $SmallWebVisitsFilterComposer + extends i0.Composer { + $SmallWebVisitsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + i3.SmallWebSourceKind, + i3.SmallWebSourceKind, + int + > + get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get mode => $composableBuilder( + column: $table.mode, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get consoleUrl => + $composableBuilder( + column: $table.consoleUrl, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get visitedAt => $composableBuilder( + column: $table.visitedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i1.$SmallWebItemsFilterComposer get itemId { + final i1.$SmallWebItemsFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebVisitsOrderingComposer + extends i0.Composer { + $SmallWebVisitsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get mode => $composableBuilder( + column: $table.mode, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get consoleUrl => $composableBuilder( + column: $table.consoleUrl, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get visitedAt => $composableBuilder( + column: $table.visitedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i1.$SmallWebItemsOrderingComposer get itemId { + final i1.$SmallWebItemsOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebVisitsAnnotationComposer + extends i0.Composer { + $SmallWebVisitsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter + get sourceKind => $composableBuilder( + column: $table.sourceKind, + builder: (column) => column, + ); + + i0.GeneratedColumn get mode => + $composableBuilder(column: $table.mode, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get consoleUrl => + $composableBuilder( + column: $table.consoleUrl, + builder: (column) => column, + ); + + i0.GeneratedColumn get visitedAt => + $composableBuilder(column: $table.visitedAt, builder: (column) => column); + + i1.$SmallWebItemsAnnotationComposer get itemId { + final i1.$SmallWebItemsAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.itemId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i1.$SmallWebItemsAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('small_web_items'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $SmallWebVisitsTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.SmallWebVisits, + i1.SmallWebVisit, + i1.$SmallWebVisitsFilterComposer, + i1.$SmallWebVisitsOrderingComposer, + i1.$SmallWebVisitsAnnotationComposer, + $SmallWebVisitsCreateCompanionBuilder, + $SmallWebVisitsUpdateCompanionBuilder, + (i1.SmallWebVisit, i1.$SmallWebVisitsReferences), + i1.SmallWebVisit, + i0.PrefetchHooks Function({bool itemId}) + > { + $SmallWebVisitsTableManager(i0.GeneratedDatabase db, i1.SmallWebVisits table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$SmallWebVisitsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$SmallWebVisitsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$SmallWebVisitsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value itemId = const i0.Value.absent(), + i0.Value sourceKind = + const i0.Value.absent(), + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + i0.Value visitedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebVisitsCompanion( + id: id, + itemId: itemId, + sourceKind: sourceKind, + mode: mode, + consoleUrl: consoleUrl, + visitedAt: visitedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + i0.Value visitedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.SmallWebVisitsCompanion.insert( + id: id, + itemId: itemId, + sourceKind: sourceKind, + mode: mode, + consoleUrl: consoleUrl, + visitedAt: visitedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$SmallWebVisitsReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({itemId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (itemId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.itemId, + referencedTable: i1.$SmallWebVisitsReferences + ._itemIdTable(db), + referencedColumn: i1.$SmallWebVisitsReferences + ._itemIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $SmallWebVisitsProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.SmallWebVisits, + i1.SmallWebVisit, + i1.$SmallWebVisitsFilterComposer, + i1.$SmallWebVisitsOrderingComposer, + i1.$SmallWebVisitsAnnotationComposer, + $SmallWebVisitsCreateCompanionBuilder, + $SmallWebVisitsUpdateCompanionBuilder, + (i1.SmallWebVisit, i1.$SmallWebVisitsReferences), + i1.SmallWebVisit, + i0.PrefetchHooks Function({bool itemId}) + >; + +class SmallWebItems extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + SmallWebItems(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final i0.GeneratedColumnWithTypeConverter url = + i0.GeneratedColumn( + 'url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL UNIQUE', + ).withConverter(i1.SmallWebItems.$converterurl); + late final i0.GeneratedColumn title = i0.GeneratedColumn( + 'title', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn domain = i0.GeneratedColumn( + 'domain', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn author = i0.GeneratedColumn( + 'author', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn summary = i0.GeneratedColumn( + 'summary', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn publishedAt = + i0.GeneratedColumn( + 'published_at', + aliasedName, + true, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + @override + List get $columns => [ + id, + url, + title, + domain, + author, + summary, + publishedAt, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'small_web_items'; + @override + Set get $primaryKey => {id}; + @override + i1.SmallWebItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.SmallWebItem( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + url: i1.SmallWebItems.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + ), + title: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}title'], + ), + domain: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + author: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}author'], + ), + summary: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}summary'], + ), + publishedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}published_at'], + ), + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + SmallWebItems createAlias(String alias) { + return SmallWebItems(attachedDatabase, alias); + } + + static i0.TypeConverter $converterurl = const i2.UriConverter(); + @override + bool get dontWriteConstraints => true; +} + +class SmallWebItem extends i0.DataClass + implements i0.Insertable { + final String id; + final Uri url; + final String? title; + final String domain; + final String? author; + final String? summary; + final DateTime? publishedAt; + final DateTime createdAt; + final DateTime updatedAt; + const SmallWebItem({ + required this.id, + required this.url, + this.title, + required this.domain, + this.author, + this.summary, + this.publishedAt, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + { + map['url'] = i0.Variable( + i1.SmallWebItems.$converterurl.toSql(url), + ); + } + if (!nullToAbsent || title != null) { + map['title'] = i0.Variable(title); + } + map['domain'] = i0.Variable(domain); + if (!nullToAbsent || author != null) { + map['author'] = i0.Variable(author); + } + if (!nullToAbsent || summary != null) { + map['summary'] = i0.Variable(summary); + } + if (!nullToAbsent || publishedAt != null) { + map['published_at'] = i0.Variable(publishedAt); + } + map['created_at'] = i0.Variable(createdAt); + map['updated_at'] = i0.Variable(updatedAt); + return map; + } + + factory SmallWebItem.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return SmallWebItem( + id: serializer.fromJson(json['id']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + domain: serializer.fromJson(json['domain']), + author: serializer.fromJson(json['author']), + summary: serializer.fromJson(json['summary']), + publishedAt: serializer.fromJson(json['published_at']), + createdAt: serializer.fromJson(json['created_at']), + updatedAt: serializer.fromJson(json['updated_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'domain': serializer.toJson(domain), + 'author': serializer.toJson(author), + 'summary': serializer.toJson(summary), + 'published_at': serializer.toJson(publishedAt), + 'created_at': serializer.toJson(createdAt), + 'updated_at': serializer.toJson(updatedAt), + }; + } + + i1.SmallWebItem copyWith({ + String? id, + Uri? url, + i0.Value title = const i0.Value.absent(), + String? domain, + i0.Value author = const i0.Value.absent(), + i0.Value summary = const i0.Value.absent(), + i0.Value publishedAt = const i0.Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => i1.SmallWebItem( + id: id ?? this.id, + url: url ?? this.url, + title: title.present ? title.value : this.title, + domain: domain ?? this.domain, + author: author.present ? author.value : this.author, + summary: summary.present ? summary.value : this.summary, + publishedAt: publishedAt.present ? publishedAt.value : this.publishedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + SmallWebItem copyWithCompanion(i1.SmallWebItemsCompanion data) { + return SmallWebItem( + id: data.id.present ? data.id.value : this.id, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + domain: data.domain.present ? data.domain.value : this.domain, + author: data.author.present ? data.author.value : this.author, + summary: data.summary.present ? data.summary.value : this.summary, + publishedAt: data.publishedAt.present + ? data.publishedAt.value + : this.publishedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SmallWebItem(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('domain: $domain, ') + ..write('author: $author, ') + ..write('summary: $summary, ') + ..write('publishedAt: $publishedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + url, + title, + domain, + author, + summary, + publishedAt, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.SmallWebItem && + other.id == this.id && + other.url == this.url && + other.title == this.title && + other.domain == this.domain && + other.author == this.author && + other.summary == this.summary && + other.publishedAt == this.publishedAt && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class SmallWebItemsCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value url; + final i0.Value title; + final i0.Value domain; + final i0.Value author; + final i0.Value summary; + final i0.Value publishedAt; + final i0.Value createdAt; + final i0.Value updatedAt; + final i0.Value rowid; + const SmallWebItemsCompanion({ + this.id = const i0.Value.absent(), + this.url = const i0.Value.absent(), + this.title = const i0.Value.absent(), + this.domain = const i0.Value.absent(), + this.author = const i0.Value.absent(), + this.summary = const i0.Value.absent(), + this.publishedAt = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + SmallWebItemsCompanion.insert({ + required String id, + required Uri url, + this.title = const i0.Value.absent(), + required String domain, + this.author = const i0.Value.absent(), + this.summary = const i0.Value.absent(), + this.publishedAt = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : id = i0.Value(id), + url = i0.Value(url), + domain = i0.Value(domain); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? url, + i0.Expression? title, + i0.Expression? domain, + i0.Expression? author, + i0.Expression? summary, + i0.Expression? publishedAt, + i0.Expression? createdAt, + i0.Expression? updatedAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (domain != null) 'domain': domain, + if (author != null) 'author': author, + if (summary != null) 'summary': summary, + if (publishedAt != null) 'published_at': publishedAt, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.SmallWebItemsCompanion copyWith({ + i0.Value? id, + i0.Value? url, + i0.Value? title, + i0.Value? domain, + i0.Value? author, + i0.Value? summary, + i0.Value? publishedAt, + i0.Value? createdAt, + i0.Value? updatedAt, + i0.Value? rowid, + }) { + return i1.SmallWebItemsCompanion( + id: id ?? this.id, + url: url ?? this.url, + title: title ?? this.title, + domain: domain ?? this.domain, + author: author ?? this.author, + summary: summary ?? this.summary, + publishedAt: publishedAt ?? this.publishedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (url.present) { + map['url'] = i0.Variable( + i1.SmallWebItems.$converterurl.toSql(url.value), + ); + } + if (title.present) { + map['title'] = i0.Variable(title.value); + } + if (domain.present) { + map['domain'] = i0.Variable(domain.value); + } + if (author.present) { + map['author'] = i0.Variable(author.value); + } + if (summary.present) { + map['summary'] = i0.Variable(summary.value); + } + if (publishedAt.present) { + map['published_at'] = i0.Variable(publishedAt.value); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SmallWebItemsCompanion(') + ..write('id: $id, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('domain: $domain, ') + ..write('author: $author, ') + ..write('summary: $summary, ') + ..write('publishedAt: $publishedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class SmallWebMemberships extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + SmallWebMemberships(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final i0.GeneratedColumn itemId = i0.GeneratedColumn( + 'item_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES small_web_items(id)ON DELETE CASCADE', + ); + late final i0.GeneratedColumnWithTypeConverter + sourceKind = + i0.GeneratedColumn( + 'source_kind', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter( + i1.SmallWebMemberships.$convertersourceKind, + ); + late final i0.GeneratedColumn mode = i0.GeneratedColumn( + 'mode', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumnWithTypeConverter consoleUrl = + i0.GeneratedColumn( + 'console_url', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(i1.SmallWebMemberships.$converterconsoleUrl); + late final i0.GeneratedColumnWithTypeConverter, String> + categories = i0.GeneratedColumn( + 'categories', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'[]\'', + defaultValue: const i0.CustomExpression('\'[]\''), + ).withConverter>(i1.SmallWebMemberships.$convertercategories); + late final i0.GeneratedColumn fetchedAt = + i0.GeneratedColumn( + 'fetched_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + @override + List get $columns => [ + id, + itemId, + sourceKind, + mode, + consoleUrl, + categories, + fetchedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'small_web_memberships'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {itemId, sourceKind, mode, consoleUrl}, + ]; + @override + i1.SmallWebMembership map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.SmallWebMembership( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + itemId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}item_id'], + )!, + sourceKind: i1.SmallWebMemberships.$convertersourceKind.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}source_kind'], + )!, + ), + mode: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}mode'], + ), + consoleUrl: i1.SmallWebMemberships.$converterconsoleUrl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}console_url'], + ), + ), + categories: i1.SmallWebMemberships.$convertercategories.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}categories'], + )!, + ), + fetchedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}fetched_at'], + )!, + ); + } + + @override + SmallWebMemberships createAlias(String alias) { + return SmallWebMemberships(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 + $convertersourceKind = const i0.EnumIndexConverter( + i3.SmallWebSourceKind.values, + ); + static i0.TypeConverter $converterconsoleUrl = + const i2.UriConverterNullable(); + static i0.TypeConverter, String> $convertercategories = + const i4.StringListConverter(); + @override + List get customConstraints => const [ + 'UNIQUE(item_id, source_kind, mode, console_url)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class SmallWebMembership extends i0.DataClass + implements i0.Insertable { + final String id; + final String itemId; + final i3.SmallWebSourceKind sourceKind; + final String? mode; + final Uri? consoleUrl; + final List categories; + final DateTime fetchedAt; + const SmallWebMembership({ + required this.id, + required this.itemId, + required this.sourceKind, + this.mode, + this.consoleUrl, + required this.categories, + required this.fetchedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['item_id'] = i0.Variable(itemId); + { + map['source_kind'] = i0.Variable( + i1.SmallWebMemberships.$convertersourceKind.toSql(sourceKind), + ); + } + if (!nullToAbsent || mode != null) { + map['mode'] = i0.Variable(mode); + } + if (!nullToAbsent || consoleUrl != null) { + map['console_url'] = i0.Variable( + i1.SmallWebMemberships.$converterconsoleUrl.toSql(consoleUrl), + ); + } + { + map['categories'] = i0.Variable( + i1.SmallWebMemberships.$convertercategories.toSql(categories), + ); + } + map['fetched_at'] = i0.Variable(fetchedAt); + return map; + } + + factory SmallWebMembership.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return SmallWebMembership( + id: serializer.fromJson(json['id']), + itemId: serializer.fromJson(json['item_id']), + sourceKind: i1.SmallWebMemberships.$convertersourceKind.fromJson( + serializer.fromJson(json['source_kind']), + ), + mode: serializer.fromJson(json['mode']), + consoleUrl: serializer.fromJson(json['console_url']), + categories: serializer.fromJson>(json['categories']), + fetchedAt: serializer.fromJson(json['fetched_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'item_id': serializer.toJson(itemId), + 'source_kind': serializer.toJson( + i1.SmallWebMemberships.$convertersourceKind.toJson(sourceKind), + ), + 'mode': serializer.toJson(mode), + 'console_url': serializer.toJson(consoleUrl), + 'categories': serializer.toJson>(categories), + 'fetched_at': serializer.toJson(fetchedAt), + }; + } + + i1.SmallWebMembership copyWith({ + String? id, + String? itemId, + i3.SmallWebSourceKind? sourceKind, + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + List? categories, + DateTime? fetchedAt, + }) => i1.SmallWebMembership( + id: id ?? this.id, + itemId: itemId ?? this.itemId, + sourceKind: sourceKind ?? this.sourceKind, + mode: mode.present ? mode.value : this.mode, + consoleUrl: consoleUrl.present ? consoleUrl.value : this.consoleUrl, + categories: categories ?? this.categories, + fetchedAt: fetchedAt ?? this.fetchedAt, + ); + SmallWebMembership copyWithCompanion(i1.SmallWebMembershipsCompanion data) { + return SmallWebMembership( + id: data.id.present ? data.id.value : this.id, + itemId: data.itemId.present ? data.itemId.value : this.itemId, + sourceKind: data.sourceKind.present + ? data.sourceKind.value + : this.sourceKind, + mode: data.mode.present ? data.mode.value : this.mode, + consoleUrl: data.consoleUrl.present + ? data.consoleUrl.value + : this.consoleUrl, + categories: data.categories.present + ? data.categories.value + : this.categories, + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SmallWebMembership(') + ..write('id: $id, ') + ..write('itemId: $itemId, ') + ..write('sourceKind: $sourceKind, ') + ..write('mode: $mode, ') + ..write('consoleUrl: $consoleUrl, ') + ..write('categories: $categories, ') + ..write('fetchedAt: $fetchedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + itemId, + sourceKind, + mode, + consoleUrl, + categories, + fetchedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.SmallWebMembership && + other.id == this.id && + other.itemId == this.itemId && + other.sourceKind == this.sourceKind && + other.mode == this.mode && + other.consoleUrl == this.consoleUrl && + other.categories == this.categories && + other.fetchedAt == this.fetchedAt); +} + +class SmallWebMembershipsCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value itemId; + final i0.Value sourceKind; + final i0.Value mode; + final i0.Value consoleUrl; + final i0.Value> categories; + final i0.Value fetchedAt; + final i0.Value rowid; + const SmallWebMembershipsCompanion({ + this.id = const i0.Value.absent(), + this.itemId = const i0.Value.absent(), + this.sourceKind = const i0.Value.absent(), + this.mode = const i0.Value.absent(), + this.consoleUrl = const i0.Value.absent(), + this.categories = const i0.Value.absent(), + this.fetchedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + SmallWebMembershipsCompanion.insert({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + this.mode = const i0.Value.absent(), + this.consoleUrl = const i0.Value.absent(), + this.categories = const i0.Value.absent(), + this.fetchedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : id = i0.Value(id), + itemId = i0.Value(itemId), + sourceKind = i0.Value(sourceKind); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? itemId, + i0.Expression? sourceKind, + i0.Expression? mode, + i0.Expression? consoleUrl, + i0.Expression? categories, + i0.Expression? fetchedAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (itemId != null) 'item_id': itemId, + if (sourceKind != null) 'source_kind': sourceKind, + if (mode != null) 'mode': mode, + if (consoleUrl != null) 'console_url': consoleUrl, + if (categories != null) 'categories': categories, + if (fetchedAt != null) 'fetched_at': fetchedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.SmallWebMembershipsCompanion copyWith({ + i0.Value? id, + i0.Value? itemId, + i0.Value? sourceKind, + i0.Value? mode, + i0.Value? consoleUrl, + i0.Value>? categories, + i0.Value? fetchedAt, + i0.Value? rowid, + }) { + return i1.SmallWebMembershipsCompanion( + id: id ?? this.id, + itemId: itemId ?? this.itemId, + sourceKind: sourceKind ?? this.sourceKind, + mode: mode ?? this.mode, + consoleUrl: consoleUrl ?? this.consoleUrl, + categories: categories ?? this.categories, + fetchedAt: fetchedAt ?? this.fetchedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (itemId.present) { + map['item_id'] = i0.Variable(itemId.value); + } + if (sourceKind.present) { + map['source_kind'] = i0.Variable( + i1.SmallWebMemberships.$convertersourceKind.toSql(sourceKind.value), + ); + } + if (mode.present) { + map['mode'] = i0.Variable(mode.value); + } + if (consoleUrl.present) { + map['console_url'] = i0.Variable( + i1.SmallWebMemberships.$converterconsoleUrl.toSql(consoleUrl.value), + ); + } + if (categories.present) { + map['categories'] = i0.Variable( + i1.SmallWebMemberships.$convertercategories.toSql(categories.value), + ); + } + if (fetchedAt.present) { + map['fetched_at'] = i0.Variable(fetchedAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SmallWebMembershipsCompanion(') + ..write('id: $id, ') + ..write('itemId: $itemId, ') + ..write('sourceKind: $sourceKind, ') + ..write('mode: $mode, ') + ..write('consoleUrl: $consoleUrl, ') + ..write('categories: $categories, ') + ..write('fetchedAt: $fetchedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxMembershipSourceMode => i0.Index( + 'idx_membership_source_mode', + 'CREATE INDEX idx_membership_source_mode ON small_web_memberships (source_kind, mode)', +); +i0.Index get idxMembershipItem => i0.Index( + 'idx_membership_item', + 'CREATE INDEX idx_membership_item ON small_web_memberships (item_id)', +); + +class WanderConsoles extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + WanderConsoles(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumnWithTypeConverter url = + i0.GeneratedColumn( + 'url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ).withConverter(i1.WanderConsoles.$converterurl); + late final i0.GeneratedColumnWithTypeConverter wanderJsUrl = + i0.GeneratedColumn( + 'wander_js_url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(i1.WanderConsoles.$converterwanderJsUrl); + late final i0.GeneratedColumn lastFetchedAt = + i0.GeneratedColumn( + 'last_fetched_at', + aliasedName, + true, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumn lastFetchFailed = + i0.GeneratedColumn( + 'last_fetch_failed', + aliasedName, + true, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumnWithTypeConverter + discoveredFromUrl = i0.GeneratedColumn( + 'discovered_from_url', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(i1.WanderConsoles.$converterdiscoveredFromUrl); + late final i0.GeneratedColumnWithTypeConverter + source = i0.GeneratedColumn( + 'source', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(i1.WanderConsoles.$convertersource); + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + @override + List get $columns => [ + url, + wanderJsUrl, + lastFetchedAt, + lastFetchFailed, + discoveredFromUrl, + source, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'wander_consoles'; + @override + Set get $primaryKey => {url}; + @override + i1.WanderConsole map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.WanderConsole( + url: i1.WanderConsoles.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + ), + wanderJsUrl: i1.WanderConsoles.$converterwanderJsUrl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}wander_js_url'], + )!, + ), + lastFetchedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}last_fetched_at'], + ), + lastFetchFailed: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}last_fetch_failed'], + ), + discoveredFromUrl: i1.WanderConsoles.$converterdiscoveredFromUrl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}discovered_from_url'], + ), + ), + source: i1.WanderConsoles.$convertersource.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + ), + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + WanderConsoles createAlias(String alias) { + return WanderConsoles(attachedDatabase, alias); + } + + static i0.TypeConverter $converterurl = const i2.UriConverter(); + static i0.TypeConverter $converterwanderJsUrl = + const i2.UriConverter(); + static i0.TypeConverter $converterdiscoveredFromUrl = + const i2.UriConverterNullable(); + static i0.JsonTypeConverter2 + $convertersource = const i0.EnumIndexConverter( + i5.WanderConsoleSource.values, + ); + @override + bool get dontWriteConstraints => true; +} + +class WanderConsole extends i0.DataClass + implements i0.Insertable { + final Uri url; + final Uri wanderJsUrl; + final DateTime? lastFetchedAt; + final bool? lastFetchFailed; + final Uri? discoveredFromUrl; + final i5.WanderConsoleSource source; + final DateTime createdAt; + const WanderConsole({ + required this.url, + required this.wanderJsUrl, + this.lastFetchedAt, + this.lastFetchFailed, + this.discoveredFromUrl, + required this.source, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['url'] = i0.Variable( + i1.WanderConsoles.$converterurl.toSql(url), + ); + } + { + map['wander_js_url'] = i0.Variable( + i1.WanderConsoles.$converterwanderJsUrl.toSql(wanderJsUrl), + ); + } + if (!nullToAbsent || lastFetchedAt != null) { + map['last_fetched_at'] = i0.Variable(lastFetchedAt); + } + if (!nullToAbsent || lastFetchFailed != null) { + map['last_fetch_failed'] = i0.Variable(lastFetchFailed); + } + if (!nullToAbsent || discoveredFromUrl != null) { + map['discovered_from_url'] = i0.Variable( + i1.WanderConsoles.$converterdiscoveredFromUrl.toSql(discoveredFromUrl), + ); + } + { + map['source'] = i0.Variable( + i1.WanderConsoles.$convertersource.toSql(source), + ); + } + map['created_at'] = i0.Variable(createdAt); + return map; + } + + factory WanderConsole.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return WanderConsole( + url: serializer.fromJson(json['url']), + wanderJsUrl: serializer.fromJson(json['wander_js_url']), + lastFetchedAt: serializer.fromJson(json['last_fetched_at']), + lastFetchFailed: serializer.fromJson(json['last_fetch_failed']), + discoveredFromUrl: serializer.fromJson(json['discovered_from_url']), + source: i1.WanderConsoles.$convertersource.fromJson( + serializer.fromJson(json['source']), + ), + createdAt: serializer.fromJson(json['created_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'url': serializer.toJson(url), + 'wander_js_url': serializer.toJson(wanderJsUrl), + 'last_fetched_at': serializer.toJson(lastFetchedAt), + 'last_fetch_failed': serializer.toJson(lastFetchFailed), + 'discovered_from_url': serializer.toJson(discoveredFromUrl), + 'source': serializer.toJson( + i1.WanderConsoles.$convertersource.toJson(source), + ), + 'created_at': serializer.toJson(createdAt), + }; + } + + i1.WanderConsole copyWith({ + Uri? url, + Uri? wanderJsUrl, + i0.Value lastFetchedAt = const i0.Value.absent(), + i0.Value lastFetchFailed = const i0.Value.absent(), + i0.Value discoveredFromUrl = const i0.Value.absent(), + i5.WanderConsoleSource? source, + DateTime? createdAt, + }) => i1.WanderConsole( + url: url ?? this.url, + wanderJsUrl: wanderJsUrl ?? this.wanderJsUrl, + lastFetchedAt: lastFetchedAt.present + ? lastFetchedAt.value + : this.lastFetchedAt, + lastFetchFailed: lastFetchFailed.present + ? lastFetchFailed.value + : this.lastFetchFailed, + discoveredFromUrl: discoveredFromUrl.present + ? discoveredFromUrl.value + : this.discoveredFromUrl, + source: source ?? this.source, + createdAt: createdAt ?? this.createdAt, + ); + WanderConsole copyWithCompanion(i1.WanderConsolesCompanion data) { + return WanderConsole( + url: data.url.present ? data.url.value : this.url, + wanderJsUrl: data.wanderJsUrl.present + ? data.wanderJsUrl.value + : this.wanderJsUrl, + lastFetchedAt: data.lastFetchedAt.present + ? data.lastFetchedAt.value + : this.lastFetchedAt, + lastFetchFailed: data.lastFetchFailed.present + ? data.lastFetchFailed.value + : this.lastFetchFailed, + discoveredFromUrl: data.discoveredFromUrl.present + ? data.discoveredFromUrl.value + : this.discoveredFromUrl, + source: data.source.present ? data.source.value : this.source, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('WanderConsole(') + ..write('url: $url, ') + ..write('wanderJsUrl: $wanderJsUrl, ') + ..write('lastFetchedAt: $lastFetchedAt, ') + ..write('lastFetchFailed: $lastFetchFailed, ') + ..write('discoveredFromUrl: $discoveredFromUrl, ') + ..write('source: $source, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + url, + wanderJsUrl, + lastFetchedAt, + lastFetchFailed, + discoveredFromUrl, + source, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.WanderConsole && + other.url == this.url && + other.wanderJsUrl == this.wanderJsUrl && + other.lastFetchedAt == this.lastFetchedAt && + other.lastFetchFailed == this.lastFetchFailed && + other.discoveredFromUrl == this.discoveredFromUrl && + other.source == this.source && + other.createdAt == this.createdAt); +} + +class WanderConsolesCompanion extends i0.UpdateCompanion { + final i0.Value url; + final i0.Value wanderJsUrl; + final i0.Value lastFetchedAt; + final i0.Value lastFetchFailed; + final i0.Value discoveredFromUrl; + final i0.Value source; + final i0.Value createdAt; + final i0.Value rowid; + const WanderConsolesCompanion({ + this.url = const i0.Value.absent(), + this.wanderJsUrl = const i0.Value.absent(), + this.lastFetchedAt = const i0.Value.absent(), + this.lastFetchFailed = const i0.Value.absent(), + this.discoveredFromUrl = const i0.Value.absent(), + this.source = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + WanderConsolesCompanion.insert({ + required Uri url, + required Uri wanderJsUrl, + this.lastFetchedAt = const i0.Value.absent(), + this.lastFetchFailed = const i0.Value.absent(), + this.discoveredFromUrl = const i0.Value.absent(), + required i5.WanderConsoleSource source, + this.createdAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : url = i0.Value(url), + wanderJsUrl = i0.Value(wanderJsUrl), + source = i0.Value(source); + static i0.Insertable custom({ + i0.Expression? url, + i0.Expression? wanderJsUrl, + i0.Expression? lastFetchedAt, + i0.Expression? lastFetchFailed, + i0.Expression? discoveredFromUrl, + i0.Expression? source, + i0.Expression? createdAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (url != null) 'url': url, + if (wanderJsUrl != null) 'wander_js_url': wanderJsUrl, + if (lastFetchedAt != null) 'last_fetched_at': lastFetchedAt, + if (lastFetchFailed != null) 'last_fetch_failed': lastFetchFailed, + if (discoveredFromUrl != null) 'discovered_from_url': discoveredFromUrl, + if (source != null) 'source': source, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.WanderConsolesCompanion copyWith({ + i0.Value? url, + i0.Value? wanderJsUrl, + i0.Value? lastFetchedAt, + i0.Value? lastFetchFailed, + i0.Value? discoveredFromUrl, + i0.Value? source, + i0.Value? createdAt, + i0.Value? rowid, + }) { + return i1.WanderConsolesCompanion( + url: url ?? this.url, + wanderJsUrl: wanderJsUrl ?? this.wanderJsUrl, + lastFetchedAt: lastFetchedAt ?? this.lastFetchedAt, + lastFetchFailed: lastFetchFailed ?? this.lastFetchFailed, + discoveredFromUrl: discoveredFromUrl ?? this.discoveredFromUrl, + source: source ?? this.source, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = i0.Variable( + i1.WanderConsoles.$converterurl.toSql(url.value), + ); + } + if (wanderJsUrl.present) { + map['wander_js_url'] = i0.Variable( + i1.WanderConsoles.$converterwanderJsUrl.toSql(wanderJsUrl.value), + ); + } + if (lastFetchedAt.present) { + map['last_fetched_at'] = i0.Variable(lastFetchedAt.value); + } + if (lastFetchFailed.present) { + map['last_fetch_failed'] = i0.Variable(lastFetchFailed.value); + } + if (discoveredFromUrl.present) { + map['discovered_from_url'] = i0.Variable( + i1.WanderConsoles.$converterdiscoveredFromUrl.toSql( + discoveredFromUrl.value, + ), + ); + } + if (source.present) { + map['source'] = i0.Variable( + i1.WanderConsoles.$convertersource.toSql(source.value), + ); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WanderConsolesCompanion(') + ..write('url: $url, ') + ..write('wanderJsUrl: $wanderJsUrl, ') + ..write('lastFetchedAt: $lastFetchedAt, ') + ..write('lastFetchFailed: $lastFetchFailed, ') + ..write('discoveredFromUrl: $discoveredFromUrl, ') + ..write('source: $source, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class WanderConsoleNeighbors extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + WanderConsoleNeighbors(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn sourceConsoleUrl = + i0.GeneratedColumn( + 'source_console_url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES wander_consoles(url)ON DELETE CASCADE', + ); + late final i0.GeneratedColumn targetConsoleUrl = + i0.GeneratedColumn( + 'target_console_url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn discoveredAt = + i0.GeneratedColumn( + 'discovered_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + @override + List get $columns => [ + sourceConsoleUrl, + targetConsoleUrl, + discoveredAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'wander_console_neighbors'; + @override + Set get $primaryKey => { + sourceConsoleUrl, + targetConsoleUrl, + }; + @override + i1.WanderConsoleNeighbor map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.WanderConsoleNeighbor( + sourceConsoleUrl: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}source_console_url'], + )!, + targetConsoleUrl: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}target_console_url'], + )!, + discoveredAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}discovered_at'], + )!, + ); + } + + @override + WanderConsoleNeighbors createAlias(String alias) { + return WanderConsoleNeighbors(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'PRIMARY KEY(source_console_url, target_console_url)', + ]; + @override + bool get dontWriteConstraints => true; +} + +class WanderConsoleNeighbor extends i0.DataClass + implements i0.Insertable { + final String sourceConsoleUrl; + final String targetConsoleUrl; + final DateTime discoveredAt; + const WanderConsoleNeighbor({ + required this.sourceConsoleUrl, + required this.targetConsoleUrl, + required this.discoveredAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['source_console_url'] = i0.Variable(sourceConsoleUrl); + map['target_console_url'] = i0.Variable(targetConsoleUrl); + map['discovered_at'] = i0.Variable(discoveredAt); + return map; + } + + factory WanderConsoleNeighbor.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return WanderConsoleNeighbor( + sourceConsoleUrl: serializer.fromJson(json['source_console_url']), + targetConsoleUrl: serializer.fromJson(json['target_console_url']), + discoveredAt: serializer.fromJson(json['discovered_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'source_console_url': serializer.toJson(sourceConsoleUrl), + 'target_console_url': serializer.toJson(targetConsoleUrl), + 'discovered_at': serializer.toJson(discoveredAt), + }; + } + + i1.WanderConsoleNeighbor copyWith({ + String? sourceConsoleUrl, + String? targetConsoleUrl, + DateTime? discoveredAt, + }) => i1.WanderConsoleNeighbor( + sourceConsoleUrl: sourceConsoleUrl ?? this.sourceConsoleUrl, + targetConsoleUrl: targetConsoleUrl ?? this.targetConsoleUrl, + discoveredAt: discoveredAt ?? this.discoveredAt, + ); + WanderConsoleNeighbor copyWithCompanion( + i1.WanderConsoleNeighborsCompanion data, + ) { + return WanderConsoleNeighbor( + sourceConsoleUrl: data.sourceConsoleUrl.present + ? data.sourceConsoleUrl.value + : this.sourceConsoleUrl, + targetConsoleUrl: data.targetConsoleUrl.present + ? data.targetConsoleUrl.value + : this.targetConsoleUrl, + discoveredAt: data.discoveredAt.present + ? data.discoveredAt.value + : this.discoveredAt, + ); + } + + @override + String toString() { + return (StringBuffer('WanderConsoleNeighbor(') + ..write('sourceConsoleUrl: $sourceConsoleUrl, ') + ..write('targetConsoleUrl: $targetConsoleUrl, ') + ..write('discoveredAt: $discoveredAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(sourceConsoleUrl, targetConsoleUrl, discoveredAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.WanderConsoleNeighbor && + other.sourceConsoleUrl == this.sourceConsoleUrl && + other.targetConsoleUrl == this.targetConsoleUrl && + other.discoveredAt == this.discoveredAt); +} + +class WanderConsoleNeighborsCompanion + extends i0.UpdateCompanion { + final i0.Value sourceConsoleUrl; + final i0.Value targetConsoleUrl; + final i0.Value discoveredAt; + final i0.Value rowid; + const WanderConsoleNeighborsCompanion({ + this.sourceConsoleUrl = const i0.Value.absent(), + this.targetConsoleUrl = const i0.Value.absent(), + this.discoveredAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + WanderConsoleNeighborsCompanion.insert({ + required String sourceConsoleUrl, + required String targetConsoleUrl, + this.discoveredAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : sourceConsoleUrl = i0.Value(sourceConsoleUrl), + targetConsoleUrl = i0.Value(targetConsoleUrl); + static i0.Insertable custom({ + i0.Expression? sourceConsoleUrl, + i0.Expression? targetConsoleUrl, + i0.Expression? discoveredAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (sourceConsoleUrl != null) 'source_console_url': sourceConsoleUrl, + if (targetConsoleUrl != null) 'target_console_url': targetConsoleUrl, + if (discoveredAt != null) 'discovered_at': discoveredAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.WanderConsoleNeighborsCompanion copyWith({ + i0.Value? sourceConsoleUrl, + i0.Value? targetConsoleUrl, + i0.Value? discoveredAt, + i0.Value? rowid, + }) { + return i1.WanderConsoleNeighborsCompanion( + sourceConsoleUrl: sourceConsoleUrl ?? this.sourceConsoleUrl, + targetConsoleUrl: targetConsoleUrl ?? this.targetConsoleUrl, + discoveredAt: discoveredAt ?? this.discoveredAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sourceConsoleUrl.present) { + map['source_console_url'] = i0.Variable(sourceConsoleUrl.value); + } + if (targetConsoleUrl.present) { + map['target_console_url'] = i0.Variable(targetConsoleUrl.value); + } + if (discoveredAt.present) { + map['discovered_at'] = i0.Variable(discoveredAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WanderConsoleNeighborsCompanion(') + ..write('sourceConsoleUrl: $sourceConsoleUrl, ') + ..write('targetConsoleUrl: $targetConsoleUrl, ') + ..write('discoveredAt: $discoveredAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class SmallWebVisits extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + SmallWebVisits(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL PRIMARY KEY', + ); + late final i0.GeneratedColumn itemId = i0.GeneratedColumn( + 'item_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES small_web_items(id)ON DELETE CASCADE', + ); + late final i0.GeneratedColumnWithTypeConverter + sourceKind = + i0.GeneratedColumn( + 'source_kind', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter( + i1.SmallWebVisits.$convertersourceKind, + ); + late final i0.GeneratedColumn mode = i0.GeneratedColumn( + 'mode', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final i0.GeneratedColumnWithTypeConverter consoleUrl = + i0.GeneratedColumn( + 'console_url', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(i1.SmallWebVisits.$converterconsoleUrl); + late final i0.GeneratedColumn visitedAt = + i0.GeneratedColumn( + 'visited_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP', + defaultValue: const i0.CustomExpression('CURRENT_TIMESTAMP'), + ); + @override + List get $columns => [ + id, + itemId, + sourceKind, + mode, + consoleUrl, + visitedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'small_web_visits'; + @override + Set get $primaryKey => {id}; + @override + i1.SmallWebVisit map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.SmallWebVisit( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + itemId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}item_id'], + )!, + sourceKind: i1.SmallWebVisits.$convertersourceKind.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}source_kind'], + )!, + ), + mode: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}mode'], + ), + consoleUrl: i1.SmallWebVisits.$converterconsoleUrl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}console_url'], + ), + ), + visitedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}visited_at'], + )!, + ); + } + + @override + SmallWebVisits createAlias(String alias) { + return SmallWebVisits(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 + $convertersourceKind = const i0.EnumIndexConverter( + i3.SmallWebSourceKind.values, + ); + static i0.TypeConverter $converterconsoleUrl = + const i2.UriConverterNullable(); + @override + bool get dontWriteConstraints => true; +} + +class SmallWebVisit extends i0.DataClass + implements i0.Insertable { + final String id; + final String itemId; + final i3.SmallWebSourceKind sourceKind; + final String? mode; + final Uri? consoleUrl; + final DateTime visitedAt; + const SmallWebVisit({ + required this.id, + required this.itemId, + required this.sourceKind, + this.mode, + this.consoleUrl, + required this.visitedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['item_id'] = i0.Variable(itemId); + { + map['source_kind'] = i0.Variable( + i1.SmallWebVisits.$convertersourceKind.toSql(sourceKind), + ); + } + if (!nullToAbsent || mode != null) { + map['mode'] = i0.Variable(mode); + } + if (!nullToAbsent || consoleUrl != null) { + map['console_url'] = i0.Variable( + i1.SmallWebVisits.$converterconsoleUrl.toSql(consoleUrl), + ); + } + map['visited_at'] = i0.Variable(visitedAt); + return map; + } + + factory SmallWebVisit.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return SmallWebVisit( + id: serializer.fromJson(json['id']), + itemId: serializer.fromJson(json['item_id']), + sourceKind: i1.SmallWebVisits.$convertersourceKind.fromJson( + serializer.fromJson(json['source_kind']), + ), + mode: serializer.fromJson(json['mode']), + consoleUrl: serializer.fromJson(json['console_url']), + visitedAt: serializer.fromJson(json['visited_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'item_id': serializer.toJson(itemId), + 'source_kind': serializer.toJson( + i1.SmallWebVisits.$convertersourceKind.toJson(sourceKind), + ), + 'mode': serializer.toJson(mode), + 'console_url': serializer.toJson(consoleUrl), + 'visited_at': serializer.toJson(visitedAt), + }; + } + + i1.SmallWebVisit copyWith({ + String? id, + String? itemId, + i3.SmallWebSourceKind? sourceKind, + i0.Value mode = const i0.Value.absent(), + i0.Value consoleUrl = const i0.Value.absent(), + DateTime? visitedAt, + }) => i1.SmallWebVisit( + id: id ?? this.id, + itemId: itemId ?? this.itemId, + sourceKind: sourceKind ?? this.sourceKind, + mode: mode.present ? mode.value : this.mode, + consoleUrl: consoleUrl.present ? consoleUrl.value : this.consoleUrl, + visitedAt: visitedAt ?? this.visitedAt, + ); + SmallWebVisit copyWithCompanion(i1.SmallWebVisitsCompanion data) { + return SmallWebVisit( + id: data.id.present ? data.id.value : this.id, + itemId: data.itemId.present ? data.itemId.value : this.itemId, + sourceKind: data.sourceKind.present + ? data.sourceKind.value + : this.sourceKind, + mode: data.mode.present ? data.mode.value : this.mode, + consoleUrl: data.consoleUrl.present + ? data.consoleUrl.value + : this.consoleUrl, + visitedAt: data.visitedAt.present ? data.visitedAt.value : this.visitedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SmallWebVisit(') + ..write('id: $id, ') + ..write('itemId: $itemId, ') + ..write('sourceKind: $sourceKind, ') + ..write('mode: $mode, ') + ..write('consoleUrl: $consoleUrl, ') + ..write('visitedAt: $visitedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, itemId, sourceKind, mode, consoleUrl, visitedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.SmallWebVisit && + other.id == this.id && + other.itemId == this.itemId && + other.sourceKind == this.sourceKind && + other.mode == this.mode && + other.consoleUrl == this.consoleUrl && + other.visitedAt == this.visitedAt); +} + +class SmallWebVisitsCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value itemId; + final i0.Value sourceKind; + final i0.Value mode; + final i0.Value consoleUrl; + final i0.Value visitedAt; + final i0.Value rowid; + const SmallWebVisitsCompanion({ + this.id = const i0.Value.absent(), + this.itemId = const i0.Value.absent(), + this.sourceKind = const i0.Value.absent(), + this.mode = const i0.Value.absent(), + this.consoleUrl = const i0.Value.absent(), + this.visitedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + SmallWebVisitsCompanion.insert({ + required String id, + required String itemId, + required i3.SmallWebSourceKind sourceKind, + this.mode = const i0.Value.absent(), + this.consoleUrl = const i0.Value.absent(), + this.visitedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }) : id = i0.Value(id), + itemId = i0.Value(itemId), + sourceKind = i0.Value(sourceKind); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? itemId, + i0.Expression? sourceKind, + i0.Expression? mode, + i0.Expression? consoleUrl, + i0.Expression? visitedAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (itemId != null) 'item_id': itemId, + if (sourceKind != null) 'source_kind': sourceKind, + if (mode != null) 'mode': mode, + if (consoleUrl != null) 'console_url': consoleUrl, + if (visitedAt != null) 'visited_at': visitedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.SmallWebVisitsCompanion copyWith({ + i0.Value? id, + i0.Value? itemId, + i0.Value? sourceKind, + i0.Value? mode, + i0.Value? consoleUrl, + i0.Value? visitedAt, + i0.Value? rowid, + }) { + return i1.SmallWebVisitsCompanion( + id: id ?? this.id, + itemId: itemId ?? this.itemId, + sourceKind: sourceKind ?? this.sourceKind, + mode: mode ?? this.mode, + consoleUrl: consoleUrl ?? this.consoleUrl, + visitedAt: visitedAt ?? this.visitedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (itemId.present) { + map['item_id'] = i0.Variable(itemId.value); + } + if (sourceKind.present) { + map['source_kind'] = i0.Variable( + i1.SmallWebVisits.$convertersourceKind.toSql(sourceKind.value), + ); + } + if (mode.present) { + map['mode'] = i0.Variable(mode.value); + } + if (consoleUrl.present) { + map['console_url'] = i0.Variable( + i1.SmallWebVisits.$converterconsoleUrl.toSql(consoleUrl.value), + ); + } + if (visitedAt.present) { + map['visited_at'] = i0.Variable(visitedAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SmallWebVisitsCompanion(') + ..write('id: $id, ') + ..write('itemId: $itemId, ') + ..write('sourceKind: $sourceKind, ') + ..write('mode: $mode, ') + ..write('consoleUrl: $consoleUrl, ') + ..write('visitedAt: $visitedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxVisitMode => i0.Index( + 'idx_visit_mode', + 'CREATE INDEX idx_visit_mode ON small_web_visits (source_kind, mode, visited_at)', +); +i0.Index get idxVisitItem => i0.Index( + 'idx_visit_item', + 'CREATE INDEX idx_visit_item ON small_web_visits (item_id)', +); + +class DefinitionsDrift extends i6.ModularAccessor { + DefinitionsDrift(i0.GeneratedDatabase db) : super(db); + i0.Selectable getDiscoverableKagiItems({ + required i3.SmallWebSourceKind sourceKind, + String? mode, + String? category, + }) { + return customSelect( + 'SELECT i.* FROM small_web_items AS i INNER JOIN small_web_memberships AS m ON m.item_id = i.id WHERE m.source_kind = ?1 AND m.mode = ?2 AND(?3 IS NULL OR EXISTS (SELECT 1 AS _c0 FROM json_each(m.categories)WHERE value = ?3))AND i.id NOT IN (SELECT v.item_id FROM small_web_visits AS v WHERE v.source_kind = ?1 AND v.mode = ?2 ORDER BY v.visited_at DESC LIMIT 20)', + variables: [ + i0.Variable( + i1.SmallWebVisits.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(mode), + i0.Variable(category), + ], + readsFrom: {smallWebItems, smallWebMemberships, smallWebVisits}, + ).asyncMap(smallWebItems.mapFromRow); + } + + i0.Selectable getRecentVisits({ + required i3.SmallWebSourceKind sourceKind, + String? mode, + required int limit, + }) { + return customSelect( + 'SELECT v.*, i.url, i.title, i.domain FROM small_web_visits AS v INNER JOIN small_web_items AS i ON i.id = v.item_id WHERE v.source_kind = ?1 AND((?2 IS NULL AND v.mode IS NULL)OR v.mode = ?2)ORDER BY v.visited_at DESC LIMIT ?3', + variables: [ + i0.Variable( + i1.SmallWebVisits.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(mode), + i0.Variable(limit), + ], + readsFrom: {smallWebItems, smallWebVisits}, + ).map( + (i0.QueryRow row) => GetRecentVisitsResult( + id: row.read('id'), + itemId: row.read('item_id'), + sourceKind: i1.SmallWebVisits.$convertersourceKind.fromSql( + row.read('source_kind'), + ), + mode: row.readNullable('mode'), + consoleUrl: i1.SmallWebVisits.$converterconsoleUrl.fromSql( + row.readNullable('console_url'), + ), + visitedAt: row.read('visited_at'), + url: i1.SmallWebItems.$converterurl.fromSql(row.read('url')), + title: row.readNullable('title'), + domain: row.read('domain'), + ), + ); + } + + i0.Selectable getDiscoveredConsoleUrls({required int limit}) { + return customSelect( + 'SELECT url FROM wander_consoles ORDER BY created_at DESC LIMIT ?1', + variables: [i0.Variable(limit)], + readsFrom: {wanderConsoles}, + ).map( + (i0.QueryRow row) => + i1.WanderConsoles.$converterurl.fromSql(row.read('url')), + ); + } + + i0.Selectable getWanderPagesForConsole({ + required i3.SmallWebSourceKind sourceKind, + required String consoleUrl, + }) { + return customSelect( + 'SELECT i.* FROM small_web_items AS i INNER JOIN small_web_memberships AS m ON m.item_id = i.id WHERE m.source_kind = ?1 AND m.console_url = CAST(?2 AS TEXT)', + variables: [ + i0.Variable( + i1.SmallWebMemberships.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(consoleUrl), + ], + readsFrom: {smallWebItems, smallWebMemberships}, + ).asyncMap(smallWebItems.mapFromRow); + } + + i0.Selectable getConsoleNeighborCount({required String consoleUrl}) { + return customSelect( + 'SELECT COUNT(*) AS c FROM wander_console_neighbors WHERE source_console_url = ?1', + variables: [i0.Variable(consoleUrl)], + readsFrom: {wanderConsoleNeighbors}, + ).map((i0.QueryRow row) => row.read('c')); + } + + i0.Selectable getAllModeItemCounts() { + return customSelect( + 'SELECT source_kind, mode, COUNT(*) AS c FROM small_web_memberships GROUP BY source_kind, mode', + variables: [], + readsFrom: {smallWebMemberships}, + ).map( + (i0.QueryRow row) => GetAllModeItemCountsResult( + sourceKind: i1.SmallWebMemberships.$convertersourceKind.fromSql( + row.read('source_kind'), + ), + mode: row.readNullable('mode'), + c: row.read('c'), + ), + ); + } + + i0.Selectable getRecentVisitItemIds({ + required i3.SmallWebSourceKind sourceKind, + String? mode, + required int limit, + }) { + return customSelect( + 'SELECT DISTINCT item_id FROM small_web_visits WHERE source_kind = ?1 AND((?2 IS NULL AND mode IS NULL)OR mode = ?2)ORDER BY visited_at DESC LIMIT ?3', + variables: [ + i0.Variable( + i1.SmallWebVisits.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(mode), + i0.Variable(limit), + ], + readsFrom: {smallWebVisits}, + ).map((i0.QueryRow row) => row.read('item_id')); + } + + i0.Selectable + getNeighborConsolesWithPageCounts({ + required i3.SmallWebSourceKind sourceKind, + required String sourceConsoleUrl, + }) { + return customSelect( + 'SELECT wc.url, wc.last_fetched_at, wc.last_fetch_failed, (SELECT COUNT(*) FROM small_web_memberships AS m WHERE m.source_kind = ?1 AND m.console_url = CAST(wc.url AS TEXT)) AS page_count FROM wander_console_neighbors AS n INNER JOIN wander_consoles AS wc ON CAST(wc.url AS TEXT) = n.target_console_url WHERE n.source_console_url = ?2 AND COALESCE(wc.last_fetch_failed, 0) = 0 ORDER BY page_count DESC', + variables: [ + i0.Variable( + i1.SmallWebMemberships.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(sourceConsoleUrl), + ], + readsFrom: {wanderConsoles, smallWebMemberships, wanderConsoleNeighbors}, + ).map( + (i0.QueryRow row) => GetNeighborConsolesWithPageCountsResult( + url: i1.WanderConsoles.$converterurl.fromSql(row.read('url')), + lastFetchedAt: row.readNullable('last_fetched_at'), + lastFetchFailed: row.readNullable('last_fetch_failed'), + pageCount: row.read('page_count'), + ), + ); + } + + i0.Selectable + getAllConsolesWithPageCounts({ + required i3.SmallWebSourceKind sourceKind, + required String query, + }) { + return customSelect( + 'SELECT wc.url, wc.last_fetched_at, wc.last_fetch_failed, (SELECT COUNT(*) FROM small_web_memberships AS m WHERE m.source_kind = ?1 AND m.console_url = CAST(wc.url AS TEXT)) AS page_count FROM wander_consoles AS wc WHERE COALESCE(wc.last_fetch_failed, 0) = 0 AND(?2 = \'\' OR CAST(wc.url AS TEXT) LIKE \'%\' || ?2 || \'%\')ORDER BY page_count DESC', + variables: [ + i0.Variable( + i1.SmallWebMemberships.$convertersourceKind.toSql(sourceKind), + ), + i0.Variable(query), + ], + readsFrom: {wanderConsoles, smallWebMemberships}, + ).map( + (i0.QueryRow row) => GetAllConsolesWithPageCountsResult( + url: i1.WanderConsoles.$converterurl.fromSql(row.read('url')), + lastFetchedAt: row.readNullable('last_fetched_at'), + lastFetchFailed: row.readNullable('last_fetch_failed'), + pageCount: row.read('page_count'), + ), + ); + } + + i1.SmallWebItems get smallWebItems => i6.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('small_web_items'); + i1.SmallWebMemberships get smallWebMemberships => i6.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('small_web_memberships'); + i1.SmallWebVisits get smallWebVisits => i6.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('small_web_visits'); + i1.WanderConsoles get wanderConsoles => i6.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('wander_consoles'); + i1.WanderConsoleNeighbors get wanderConsoleNeighbors => + i6.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('wander_console_neighbors'); +} + +class GetRecentVisitsResult { + final String id; + final String itemId; + final i3.SmallWebSourceKind sourceKind; + final String? mode; + final Uri? consoleUrl; + final DateTime visitedAt; + final Uri url; + final String? title; + final String domain; + GetRecentVisitsResult({ + required this.id, + required this.itemId, + required this.sourceKind, + this.mode, + this.consoleUrl, + required this.visitedAt, + required this.url, + this.title, + required this.domain, + }); +} + +class GetAllModeItemCountsResult { + final i3.SmallWebSourceKind sourceKind; + final String? mode; + final int c; + GetAllModeItemCountsResult({ + required this.sourceKind, + this.mode, + required this.c, + }); +} + +class GetNeighborConsolesWithPageCountsResult { + final Uri url; + final DateTime? lastFetchedAt; + final bool? lastFetchFailed; + final int pageCount; + GetNeighborConsolesWithPageCountsResult({ + required this.url, + this.lastFetchedAt, + this.lastFetchFailed, + required this.pageCount, + }); +} + +class GetAllConsolesWithPageCountsResult { + final Uri url; + final DateTime? lastFetchedAt; + final bool? lastFetchFailed; + final int pageCount; + GetAllConsolesWithPageCountsResult({ + required this.url, + this.lastFetchedAt, + this.lastFetchFailed, + required this.pageCount, + }); +} diff --git a/app/lib/features/small_web/data/models/kagi_category.dart b/app/lib/features/small_web/data/models/kagi_category.dart new file mode 100644 index 00000000..a9682c0b --- /dev/null +++ b/app/lib/features/small_web/data/models/kagi_category.dart @@ -0,0 +1,77 @@ +/* + * 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 . + */ + +class KagiCategoryDefinition { + final String slug; + final String label; + final String description; + final String emoji; + + const KagiCategoryDefinition({ + required this.slug, + required this.label, + required this.description, + required this.emoji, + }); +} + +class KagiCategories { + final Map categories; + final Map> groups; + final Map remap; + + const KagiCategories({ + required this.categories, + required this.groups, + required this.remap, + }); + + factory KagiCategories.fromJson(Map json) { + final rawCategories = json['categories'] as Map; + final categories = rawCategories.map( + (slug, data) => MapEntry( + slug, + KagiCategoryDefinition( + slug: slug, + label: (data as Map)['label'] as String, + description: data['description'] as String, + emoji: data['emoji'] as String, + ), + ), + ); + + final rawGroups = json['groups'] as Map; + final groups = rawGroups.map( + (name, slugs) => MapEntry( + name, + (slugs as List).cast(), + ), + ); + + final rawRemap = json['remap'] as Map; + final remap = rawRemap.cast(); + + return KagiCategories( + categories: categories, + groups: groups, + remap: remap, + ); + } +} diff --git a/app/lib/features/small_web/data/models/kagi_feed_entry.dart b/app/lib/features/small_web/data/models/kagi_feed_entry.dart new file mode 100644 index 00000000..b05695a9 --- /dev/null +++ b/app/lib/features/small_web/data/models/kagi_feed_entry.dart @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +import 'package:fast_equatable/fast_equatable.dart'; + +class KagiFeedEntry with FastEquatable { + final Uri url; + final String? title; + final String? author; + final String? summary; + final DateTime? publishedAt; + final List categories; + + KagiFeedEntry({ + required this.url, + this.title, + this.author, + this.summary, + this.publishedAt, + this.categories = const [], + }); + + @override + List get hashParameters => [ + url, + title, + author, + summary, + publishedAt, + categories, + ]; +} diff --git a/app/lib/features/small_web/data/models/kagi_small_web_mode.dart b/app/lib/features/small_web/data/models/kagi_small_web_mode.dart new file mode 100644 index 00000000..3409d9aa --- /dev/null +++ b/app/lib/features/small_web/data/models/kagi_small_web_mode.dart @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +import 'package:flutter/material.dart'; + +enum KagiSmallWebMode { + web('Web', Icons.language, 'https://kagi.com/api/v1/smallweb/feed?nso'), + appreciated( + 'Appreciated', + Icons.thumb_up_outlined, + 'https://kagi.com/smallweb/appreciated', + ), + videos( + 'Videos', + Icons.play_circle_outline, + 'https://kagi.com/api/v1/smallweb/feed?yt', + ), + code('Code', Icons.code, 'https://kagi.com/api/v1/smallweb/feed?gh'), + comics( + 'Comics', + Icons.auto_stories, + 'https://kagi.com/api/v1/smallweb/feed?comic', + ); + + final String label; + final IconData icon; + final String feedUrlString; + + const KagiSmallWebMode(this.label, this.icon, this.feedUrlString); + + Uri get feedUrl => Uri.parse(feedUrlString); +} diff --git a/app/lib/features/small_web/data/models/small_web_source_kind.dart b/app/lib/features/small_web/data/models/small_web_source_kind.dart new file mode 100644 index 00000000..528a3007 --- /dev/null +++ b/app/lib/features/small_web/data/models/small_web_source_kind.dart @@ -0,0 +1,32 @@ +/* + * 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 . + */ + +import 'package:flutter/material.dart'; + +enum SmallWebSourceKind { + kagi('Kagi', Icons.travel_explore, 'Small Web by Kagi Search'), + wander('Wander', Icons.dns, 'Console-based web ring'); + + final String label; + final IconData icon; + final String description; + + const SmallWebSourceKind(this.label, this.icon, this.description); +} diff --git a/app/lib/features/small_web/data/models/wander_console_source.dart b/app/lib/features/small_web/data/models/wander_console_source.dart new file mode 100644 index 00000000..9b9ebf27 --- /dev/null +++ b/app/lib/features/small_web/data/models/wander_console_source.dart @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +enum WanderConsoleSource { seed, manual, discovered } diff --git a/app/lib/features/small_web/data/providers.dart b/app/lib/features/small_web/data/providers.dart new file mode 100644 index 00000000..97a6f630 --- /dev/null +++ b/app/lib/features/small_web/data/providers.dart @@ -0,0 +1,68 @@ +/* + * 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 . + */ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; +import 'package:weblibre/core/database_registry.dart'; +import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_category.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +SmallWebDatabase smallWebDatabase(Ref ref) { + final db = SmallWebDatabase( + LazyDatabase(() async { + final file = File( + p.join(filesystem.profileDatabasesDir.path, 'small_web.db'), + ); + + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + return NativeDatabase.createInBackground(file); + }), + ); + + DatabaseRegistry.instance.register('small_web', db); + + ref.onDispose(() async { + await db.close(); + }); + + return db; +} + +@Riverpod(keepAlive: true) +Future kagiCategories(Ref ref) async { + final jsonStr = await rootBundle.loadString( + 'assets/small_web/kagi_categories.json', + ); + + return KagiCategories.fromJson(jsonDecode(jsonStr) as Map); +} diff --git a/app/lib/features/small_web/data/providers.g.dart b/app/lib/features/small_web/data/providers.g.dart new file mode 100644 index 00000000..f0661e23 --- /dev/null +++ b/app/lib/features/small_web/data/providers.g.dart @@ -0,0 +1,95 @@ +// 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(smallWebDatabase) +final smallWebDatabaseProvider = SmallWebDatabaseProvider._(); + +final class SmallWebDatabaseProvider + extends + $FunctionalProvider< + SmallWebDatabase, + SmallWebDatabase, + SmallWebDatabase + > + with $Provider { + SmallWebDatabaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'smallWebDatabaseProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebDatabaseHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + SmallWebDatabase create(Ref ref) { + return smallWebDatabase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SmallWebDatabase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$smallWebDatabaseHash() => r'e199e8c1261e8152110fb99f883fed1e550ffdc6'; + +@ProviderFor(kagiCategories) +final kagiCategoriesProvider = KagiCategoriesProvider._(); + +final class KagiCategoriesProvider + extends + $FunctionalProvider< + AsyncValue, + KagiCategories, + FutureOr + > + with $FutureModifier, $FutureProvider { + KagiCategoriesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'kagiCategoriesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$kagiCategoriesHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return kagiCategories(ref); + } +} + +String _$kagiCategoriesHash() => r'de97daabc1aba5360209e15f1de0e5488435aa54'; diff --git a/app/lib/features/small_web/data/wander_seed_consoles.dart b/app/lib/features/small_web/data/wander_seed_consoles.dart new file mode 100644 index 00000000..28287070 --- /dev/null +++ b/app/lib/features/small_web/data/wander_seed_consoles.dart @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +const wanderSeedConsoles = ['https://susam.net/wander/']; diff --git a/app/lib/features/small_web/domain/providers.dart b/app/lib/features/small_web/domain/providers.dart new file mode 100644 index 00000000..fa5b1c0e --- /dev/null +++ b/app/lib/features/small_web/domain/providers.dart @@ -0,0 +1,137 @@ +/* + * 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 . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/providers.dart'; +import 'package:weblibre/features/small_web/domain/services/kagi_source_service.dart'; +import 'package:weblibre/features/small_web/domain/services/small_web_discover_service.dart'; +import 'package:weblibre/features/small_web/domain/services/wander_source_service.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +Future kagiSourceService(Ref ref) async { + final categories = await ref.watch(kagiCategoriesProvider.future); + return KagiSourceService( + ref.watch(smallWebDatabaseProvider), + categories.remap, + ); +} + +@Riverpod(keepAlive: true) +WanderSourceService wanderSourceService(Ref ref) { + return WanderSourceService(ref.watch(smallWebDatabaseProvider)); +} + +@Riverpod(keepAlive: true) +Future smallWebDiscoverService(Ref ref) async { + final kagiService = await ref.watch(kagiSourceServiceProvider.future); + + return SmallWebDiscoverService( + ref.watch(smallWebDatabaseProvider), + kagiService, + ref.watch(wanderSourceServiceProvider), + ); +} + +@Riverpod() +Stream> smallWebRecentVisits( + Ref ref, + SmallWebSourceKind sourceKind, + KagiSmallWebMode? mode, +) { + final db = ref.watch(smallWebDatabaseProvider); + return db.smallWebVisitDao + .getRecentVisits(sourceKind: sourceKind, mode: mode) + .watch(); +} + +@Riverpod() +Stream> smallWebAllModeItemCounts(Ref ref) { + final db = ref.watch(smallWebDatabaseProvider); + return db.definitionsDrift.getAllModeItemCounts().watch().map((rows) { + final counts = {}; + for (final row in rows) { + if (row.mode == null) continue; + final mode = KagiSmallWebMode.values + .where((m) => m.name == row.mode) + .firstOrNull; + if (mode != null) counts[mode] = (counts[mode] ?? 0) + row.c; + } + return counts; + }); +} + +@Riverpod() +Stream<({int linkedConsoles, int pages})> wanderConsoleStats( + Ref ref, + Uri consoleUrl, +) { + final db = ref.watch(smallWebDatabaseProvider); + + final linkedConsoles = db.definitionsDrift + .getConsoleNeighborCount(consoleUrl: consoleUrl.toString()) + .watchSingle(); + + final pages = db.definitionsDrift + .getWanderPagesForConsole( + sourceKind: SmallWebSourceKind.wander, + consoleUrl: consoleUrl.toString(), + ) + .watch(); + + return CombineLatestStream.combine2( + linkedConsoles, + pages, + (a, b) => (linkedConsoles: a, pages: b.length), + ); +} + +@Riverpod() +Stream> wanderNeighborConsoles( + Ref ref, + Uri consoleUrl, +) { + final db = ref.watch(smallWebDatabaseProvider); + + return db.definitionsDrift + .getNeighborConsolesWithPageCounts( + sourceKind: SmallWebSourceKind.wander, + sourceConsoleUrl: consoleUrl.toString(), + ) + .watch(); +} + +@Riverpod() +Stream> wanderAllConsoles( + Ref ref, + String query, +) { + final db = ref.watch(smallWebDatabaseProvider); + return db.definitionsDrift + .getAllConsolesWithPageCounts( + sourceKind: SmallWebSourceKind.wander, + query: query, + ) + .watch(); +} diff --git a/app/lib/features/small_web/domain/providers.g.dart b/app/lib/features/small_web/domain/providers.g.dart new file mode 100644 index 00000000..e301a5bb --- /dev/null +++ b/app/lib/features/small_web/domain/providers.g.dart @@ -0,0 +1,511 @@ +// 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(kagiSourceService) +final kagiSourceServiceProvider = KagiSourceServiceProvider._(); + +final class KagiSourceServiceProvider + extends + $FunctionalProvider< + AsyncValue, + KagiSourceService, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + KagiSourceServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'kagiSourceServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$kagiSourceServiceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return kagiSourceService(ref); + } +} + +String _$kagiSourceServiceHash() => r'7f0cca556d22cc65356660f3e59c976a35d40d37'; + +@ProviderFor(wanderSourceService) +final wanderSourceServiceProvider = WanderSourceServiceProvider._(); + +final class WanderSourceServiceProvider + extends + $FunctionalProvider< + WanderSourceService, + WanderSourceService, + WanderSourceService + > + with $Provider { + WanderSourceServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'wanderSourceServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wanderSourceServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + WanderSourceService create(Ref ref) { + return wanderSourceService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(WanderSourceService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$wanderSourceServiceHash() => + r'80574ea0c51f56325940edb0fa7e849784ba7421'; + +@ProviderFor(smallWebDiscoverService) +final smallWebDiscoverServiceProvider = SmallWebDiscoverServiceProvider._(); + +final class SmallWebDiscoverServiceProvider + extends + $FunctionalProvider< + AsyncValue, + SmallWebDiscoverService, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + SmallWebDiscoverServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'smallWebDiscoverServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebDiscoverServiceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return smallWebDiscoverService(ref); + } +} + +String _$smallWebDiscoverServiceHash() => + r'14fba9eb1c2aa51fa1fac2c2c764ea400603e8ca'; + +@ProviderFor(smallWebRecentVisits) +final smallWebRecentVisitsProvider = SmallWebRecentVisitsFamily._(); + +final class SmallWebRecentVisitsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + SmallWebRecentVisitsProvider._({ + required SmallWebRecentVisitsFamily super.from, + required (SmallWebSourceKind, KagiSmallWebMode?) super.argument, + }) : super( + retry: null, + name: r'smallWebRecentVisitsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebRecentVisitsHash(); + + @override + String toString() { + return r'smallWebRecentVisitsProvider' + '' + '$argument'; + } + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + final argument = this.argument as (SmallWebSourceKind, KagiSmallWebMode?); + return smallWebRecentVisits(ref, argument.$1, argument.$2); + } + + @override + bool operator ==(Object other) { + return other is SmallWebRecentVisitsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$smallWebRecentVisitsHash() => + r'ec7d837523b856a6e7e828c0f26a97913e2af34a'; + +final class SmallWebRecentVisitsFamily extends $Family + with + $FunctionalFamilyOverride< + Stream>, + (SmallWebSourceKind, KagiSmallWebMode?) + > { + SmallWebRecentVisitsFamily._() + : super( + retry: null, + name: r'smallWebRecentVisitsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + SmallWebRecentVisitsProvider call( + SmallWebSourceKind sourceKind, + KagiSmallWebMode? mode, + ) => SmallWebRecentVisitsProvider._(argument: (sourceKind, mode), from: this); + + @override + String toString() => r'smallWebRecentVisitsProvider'; +} + +@ProviderFor(smallWebAllModeItemCounts) +final smallWebAllModeItemCountsProvider = SmallWebAllModeItemCountsProvider._(); + +final class SmallWebAllModeItemCountsProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + SmallWebAllModeItemCountsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'smallWebAllModeItemCountsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebAllModeItemCountsHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return smallWebAllModeItemCounts(ref); + } +} + +String _$smallWebAllModeItemCountsHash() => + r'64d50f9fe581003d6f1e7df61b859118a5ad31fa'; + +@ProviderFor(wanderConsoleStats) +final wanderConsoleStatsProvider = WanderConsoleStatsFamily._(); + +final class WanderConsoleStatsProvider + extends + $FunctionalProvider< + AsyncValue<({int linkedConsoles, int pages})>, + ({int linkedConsoles, int pages}), + Stream<({int linkedConsoles, int pages})> + > + with + $FutureModifier<({int linkedConsoles, int pages})>, + $StreamProvider<({int linkedConsoles, int pages})> { + WanderConsoleStatsProvider._({ + required WanderConsoleStatsFamily super.from, + required Uri super.argument, + }) : super( + retry: null, + name: r'wanderConsoleStatsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wanderConsoleStatsHash(); + + @override + String toString() { + return r'wanderConsoleStatsProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement<({int linkedConsoles, int pages})> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream<({int linkedConsoles, int pages})> create(Ref ref) { + final argument = this.argument as Uri; + return wanderConsoleStats(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WanderConsoleStatsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$wanderConsoleStatsHash() => + r'201d2b0669b878762c3d535f39f6d04e030b8112'; + +final class WanderConsoleStatsFamily extends $Family + with + $FunctionalFamilyOverride< + Stream<({int linkedConsoles, int pages})>, + Uri + > { + WanderConsoleStatsFamily._() + : super( + retry: null, + name: r'wanderConsoleStatsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WanderConsoleStatsProvider call(Uri consoleUrl) => + WanderConsoleStatsProvider._(argument: consoleUrl, from: this); + + @override + String toString() => r'wanderConsoleStatsProvider'; +} + +@ProviderFor(wanderNeighborConsoles) +final wanderNeighborConsolesProvider = WanderNeighborConsolesFamily._(); + +final class WanderNeighborConsolesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + WanderNeighborConsolesProvider._({ + required WanderNeighborConsolesFamily super.from, + required Uri super.argument, + }) : super( + retry: null, + name: r'wanderNeighborConsolesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wanderNeighborConsolesHash(); + + @override + String toString() { + return r'wanderNeighborConsolesProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement> + $createElement($ProviderPointer pointer) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + final argument = this.argument as Uri; + return wanderNeighborConsoles(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WanderNeighborConsolesProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$wanderNeighborConsolesHash() => + r'08393800b649cb88e21281dad02a7ed6c745797e'; + +final class WanderNeighborConsolesFamily extends $Family + with + $FunctionalFamilyOverride< + Stream>, + Uri + > { + WanderNeighborConsolesFamily._() + : super( + retry: null, + name: r'wanderNeighborConsolesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WanderNeighborConsolesProvider call(Uri consoleUrl) => + WanderNeighborConsolesProvider._(argument: consoleUrl, from: this); + + @override + String toString() => r'wanderNeighborConsolesProvider'; +} + +@ProviderFor(wanderAllConsoles) +final wanderAllConsolesProvider = WanderAllConsolesFamily._(); + +final class WanderAllConsolesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + WanderAllConsolesProvider._({ + required WanderAllConsolesFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'wanderAllConsolesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wanderAllConsolesHash(); + + @override + String toString() { + return r'wanderAllConsolesProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement> + $createElement($ProviderPointer pointer) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + final argument = this.argument as String; + return wanderAllConsoles(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WanderAllConsolesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$wanderAllConsolesHash() => r'27d87bbad8fb9e993de36c70649b324a98c8bf87'; + +final class WanderAllConsolesFamily extends $Family + with + $FunctionalFamilyOverride< + Stream>, + String + > { + WanderAllConsolesFamily._() + : super( + retry: null, + name: r'wanderAllConsolesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WanderAllConsolesProvider call(String query) => + WanderAllConsolesProvider._(argument: query, from: this); + + @override + String toString() => r'wanderAllConsolesProvider'; +} diff --git a/app/lib/features/small_web/domain/services/kagi_source_service.dart b/app/lib/features/small_web/domain/services/kagi_source_service.dart new file mode 100644 index 00000000..4a054033 --- /dev/null +++ b/app/lib/features/small_web/domain/services/kagi_source_service.dart @@ -0,0 +1,231 @@ +/* + * 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 . + */ +import 'dart:isolate'; + +import 'package:drift/drift.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:rss_dart/dart_rss.dart'; +import 'package:uuid/enums.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/extensions/http_encoding.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_feed_entry.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; + +const _staleDuration = Duration(hours: 3); + +typedef _KagiFeedFetchRequest = ({ + RootIsolateToken token, + String url, + String mode, + Map categoryRemap, +}); + +class KagiSourceService { + final SmallWebDatabase _db; + final Map _categoryRemap; + + KagiSourceService(this._db, this._categoryRemap); + + Future needsRefresh(KagiSmallWebMode mode) async { + final latestFetch = await _db.smallWebItemDao + .getLatestFetchedAt(SmallWebSourceKind.kagi, mode) + .getSingleOrNull(); + + if (latestFetch == null) return true; + + return DateTime.now().difference(latestFetch) > _staleDuration; + } + + Future fetchAndIngest(KagiSmallWebMode mode) async { + final request = ( + token: ServicesBinding.rootIsolateToken!, + url: mode.feedUrl.toString(), + mode: mode.name, + categoryRemap: Map.from(_categoryRemap), + ); + + final List entries; + try { + entries = await _runKagiFeedFetch(request); + } catch (e, st) { + logger.e( + 'Failed to fetch/parse Kagi feed for $mode', + error: e, + stackTrace: st, + ); + rethrow; + } + + final now = DateTime.now(); + await _db.batch((batch) { + for (final entry in entries) { + final itemId = uuid.v5(Namespace.url.value, entry.url.toString()); + + batch.insert( + _db.smallWebItems, + SmallWebItemsCompanion.insert( + id: itemId, + url: entry.url, + title: Value(entry.title), + domain: entry.url.host, + author: Value(entry.author), + summary: Value(entry.summary), + publishedAt: Value(entry.publishedAt), + createdAt: Value(now), + updatedAt: Value(now), + ), + onConflict: DoUpdate( + (old) => SmallWebItemsCompanion( + title: entry.title != null + ? Value(entry.title) + : const Value.absent(), + author: entry.author != null + ? Value(entry.author) + : const Value.absent(), + summary: entry.summary != null + ? Value(entry.summary) + : const Value.absent(), + publishedAt: entry.publishedAt != null + ? Value(entry.publishedAt) + : const Value.absent(), + updatedAt: Value(now), + ), + target: [_db.smallWebItems.url], + ), + ); + + final membershipId = uuid.v5( + Namespace.url.value, + '${SmallWebSourceKind.kagi.name}:${mode.name}:${entry.url}', + ); + + batch.insert( + _db.smallWebMemberships, + SmallWebMembershipsCompanion.insert( + id: membershipId, + itemId: itemId, + sourceKind: SmallWebSourceKind.kagi, + mode: Value(mode.name), + consoleUrl: const Value(null), + categories: Value(entry.categories), + fetchedAt: Value(now), + ), + onConflict: DoUpdate( + (old) => SmallWebMembershipsCompanion( + categories: Value(entry.categories), + fetchedAt: Value(now), + ), + target: [_db.smallWebMemberships.id], + ), + ); + } + }); + } +} + +Future> _runKagiFeedFetch(_KagiFeedFetchRequest request) { + return Isolate.run(_createKagiFeedFetchTask(request)); +} + +Future> Function() _createKagiFeedFetchTask( + _KagiFeedFetchRequest request, +) { + return () => _fetchAndParseFeed( + request.token, + Uri.parse(request.url), + request.mode, + request.categoryRemap, + ); +} + +Future> _fetchAndParseFeed( + RootIsolateToken token, + Uri url, + String mode, + Map categoryRemap, +) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final client = http.Client(); + try { + final response = await client.get(url).timeout(const Duration(seconds: 30)); + + if (response.statusCode != 200) { + throw Exception( + 'Kagi feed request failed with status ${response.statusCode}', + ); + } + + final xmlString = response.bodyUnicodeFallback; + final feed = AtomFeed.parse(xmlString); + + return feed.items + .map((item) { + final link = item.links + .where((l) => l.rel == 'alternate' || l.rel == null) + .map((l) => l.href) + .firstOrNull; + + final href = link ?? item.links.firstOrNull?.href; + final parsedUrl = href != null ? Uri.tryParse(href) : null; + if (parsedUrl == null) return null; + + if (mode == 'videos' && parsedUrl.path.contains('/shorts/')) { + return null; + } + + const kagiScheme = 'https://kagi.com/smallweb/categories'; + final categories = item.categories + .where( + (c) => + c.scheme == kagiScheme && + c.term != null && + c.term!.isNotEmpty, + ) + .map((c) => categoryRemap[c.term!] ?? c.term!) + .toList(); + + final author = item.authors + .where((a) => a.name != null && a.name!.isNotEmpty) + .map((a) => a.name!) + .firstOrNull; + + return KagiFeedEntry( + url: parsedUrl, + title: item.title, + author: author, + summary: item.summary, + publishedAt: item.updated != null + ? DateTime.tryParse(item.updated!) + : null, + categories: categories, + ); + }) + .nonNulls + .toList(); + } finally { + client.close(); + } +} diff --git a/app/lib/features/small_web/domain/services/small_web_discover_service.dart b/app/lib/features/small_web/domain/services/small_web_discover_service.dart new file mode 100644 index 00000000..b85ba364 --- /dev/null +++ b/app/lib/features/small_web/domain/services/small_web_discover_service.dart @@ -0,0 +1,205 @@ +/* + * 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 . + */ +import 'dart:math'; + +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/models/wander_console_source.dart'; +import 'package:weblibre/features/small_web/domain/services/kagi_source_service.dart'; +import 'package:weblibre/features/small_web/domain/services/wander_source_service.dart'; + +final _random = Random.secure(); + +class WanderDiscoverResult with FastEquatable { + final SmallWebItem item; + final Uri consoleUrl; + + WanderDiscoverResult({required this.item, required this.consoleUrl}); + + @override + List get hashParameters => [item, consoleUrl]; +} + +class SmallWebDiscoverService { + final SmallWebDatabase _db; + final KagiSourceService _kagiService; + final WanderSourceService _wanderService; + + SmallWebDiscoverService(this._db, this._kagiService, this._wanderService); + + Future recordVisit({ + required String itemId, + required SmallWebSourceKind sourceKind, + required KagiSmallWebMode? mode, + Uri? consoleUrl, + }) async { + await _db.smallWebVisitDao.insertVisit( + SmallWebVisit( + id: uuid.v4(), + itemId: itemId, + sourceKind: sourceKind, + mode: mode?.name, + consoleUrl: consoleUrl, + visitedAt: DateTime.now(), + ), + ); + } + + Future discoverKagi({ + required KagiSmallWebMode mode, + String? category, + }) async { + if (await _kagiService.needsRefresh(mode)) { + await _kagiService.fetchAndIngest(mode); + } + + final items = await _db.smallWebItemDao + .getDiscoverableKagiItems(mode, category) + .get(); + + if (items.isEmpty) return null; + + final picked = items[_random.nextInt(items.length)]; + + await recordVisit( + itemId: picked.id, + sourceKind: SmallWebSourceKind.kagi, + mode: mode, + ); + + return picked; + } + + Future discoverWander({ + Uri? currentConsoleUrl, + bool forceNewConsole = false, + }) async { + await _wanderService.syncSeeds(); + + final recentItemIds = + (await _db.smallWebVisitDao + .getRecentItemIds( + sourceKind: SmallWebSourceKind.wander, + mode: null, + ) + .get()) + .toSet(); + + // Pick a console to explore + Uri consoleUrl; + if (currentConsoleUrl != null && !forceNewConsole) { + consoleUrl = currentConsoleUrl; + } else { + final consoleUrls = await _wanderService.getDiscoveredConsoleUrls(); + + if (forceNewConsole && currentConsoleUrl != null) { + consoleUrls.remove(currentConsoleUrl); + } + + if (consoleUrls.isEmpty) return null; + + consoleUrl = consoleUrls[_random.nextInt(consoleUrls.length)]; + } + + final pages = await _refreshAndGetPages(consoleUrl, forceRetry: true); + final unvisitedPages = pages + .where((page) => !recentItemIds.contains(page.id)) + .toList(); + + if (unvisitedPages.isNotEmpty) { + return _pickAndRecord(unvisitedPages, consoleUrl); + } + + // No unvisited pages on this console — try alternatives + final result = await _tryAlternativeConsoles(consoleUrl, recentItemIds); + if (result != null) return result; + + // Last resort: revisit a page from the original console + if (pages.isEmpty) return null; + return _pickAndRecord(pages, consoleUrl); + } + + Future updateItemTitle(String itemId, String title) { + return _db.smallWebItemDao.updateTitle(itemId, title); + } + + Future> _refreshAndGetPages( + Uri consoleUrl, { + bool forceRetry = false, + }) async { + if (await _wanderService.shouldRefreshConsole( + consoleUrl, + forceRetry: forceRetry, + )) { + await _wanderService.fetchAndIngestConsole( + consoleUrl, + source: WanderConsoleSource.discovered, + ); + } + return _wanderService.getPagesForConsole(consoleUrl); + } + + Future _tryAlternativeConsoles( + Uri excludeConsole, + Set recentItemIds, + ) async { + final allConsoles = await _wanderService.getDiscoveredConsoleUrls() + ..remove(excludeConsole) + ..shuffle(_random); + + for (final altConsole in allConsoles.take(10)) { + try { + final altPages = await _refreshAndGetPages(altConsole); + final unvisited = altPages + .where((p) => !recentItemIds.contains(p.id)) + .toList(); + final candidates = unvisited.isNotEmpty ? unvisited : altPages; + + if (candidates.isNotEmpty) { + return _pickAndRecord(candidates, altConsole); + } + } catch (_) { + // Skip consoles that fail to fetch; continue trying others. + } + } + + return null; + } + + Future _pickAndRecord( + List candidates, + Uri consoleUrl, + ) async { + final picked = candidates[_random.nextInt(candidates.length)]; + + await recordVisit( + itemId: picked.id, + sourceKind: SmallWebSourceKind.wander, + mode: null, + consoleUrl: consoleUrl, + ); + + return WanderDiscoverResult(item: picked, consoleUrl: consoleUrl); + } +} diff --git a/app/lib/features/small_web/domain/services/wander_js_parser.dart b/app/lib/features/small_web/domain/services/wander_js_parser.dart new file mode 100644 index 00000000..d028da58 --- /dev/null +++ b/app/lib/features/small_web/domain/services/wander_js_parser.dart @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +import 'package:weblibre/extensions/uri.dart'; + +final _lineCommentPattern = RegExp(r'(? _extractArray(String source, RegExp pattern) { + final match = pattern.firstMatch(source); + if (match == null) return []; + + final arrayContent = match.group(1) ?? ''; + return _stringPattern + .allMatches(arrayContent) + .map((m) => m.group(1)!) + .toList(); +} + +Uri _normalizeUrl(String url) { + var normalized = url; + + if (normalized.endsWith('/index.html')) { + normalized = normalized.substring( + 0, + normalized.length - 'index.html'.length, + ); + } + + return Uri.parse(normalized); +} + +class WanderJsResult { + final List consoles; + final List pages; + + const WanderJsResult({required this.consoles, required this.pages}); + + factory WanderJsResult.parse(String jsSource) { + final cleaned = jsSource + .replaceAll(_blockCommentPattern, '') + .replaceAll(_lineCommentPattern, ''); + + final consoles = _extractArray(cleaned, _consolesPattern); + final pages = _extractArray(cleaned, _pagesPattern); + + return WanderJsResult( + consoles: consoles + .map(_normalizeUrl) + .where((uri) => uri.isHttpOrHttps) + .toList(), + pages: pages + .map(_normalizeUrl) + .where((uri) => uri.isHttpOrHttps) + .toList(), + ); + } +} diff --git a/app/lib/features/small_web/domain/services/wander_source_service.dart b/app/lib/features/small_web/domain/services/wander_source_service.dart new file mode 100644 index 00000000..6e509da4 --- /dev/null +++ b/app/lib/features/small_web/domain/services/wander_source_service.dart @@ -0,0 +1,341 @@ +/* + * 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 . + */ +import 'dart:isolate'; + +import 'package:drift/drift.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:uuid/enums.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/small_web/data/database/database.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/models/wander_console_source.dart'; +import 'package:weblibre/features/small_web/data/wander_seed_consoles.dart'; +import 'package:weblibre/features/small_web/domain/services/wander_js_parser.dart'; + +const _staleDuration = Duration(hours: 3); +const _retryAfterError = Duration(minutes: 30); + +typedef _WanderJsFetchRequest = ({RootIsolateToken token, String url}); + +class WanderSourceService { + final SmallWebDatabase _db; + + WanderSourceService(this._db); + + Future shouldRefreshConsole( + Uri consoleUrl, { + bool forceRetry = false, + }) async { + final console = await _db.wanderConsoleDao + .getConsole(consoleUrl) + .getSingleOrNull(); + + if (console == null || console.lastFetchedAt == null) return true; + + final age = DateTime.now().difference(console.lastFetchedAt!); + if (console.lastFetchFailed == true) { + return forceRetry || age > _retryAfterError; + } + + return age > _staleDuration; + } + + Future syncSeeds() async { + final now = DateTime.now(); + await _db.batch((batch) { + for (final seedUrl in wanderSeedConsoles) { + final url = Uri.parse(seedUrl); + batch.insert( + _db.wanderConsoles, + WanderConsolesCompanion.insert( + url: url, + wanderJsUrl: url.resolve('wander.js'), + source: WanderConsoleSource.seed, + createdAt: Value(now), + ), + onConflict: DoNothing(), + ); + } + }); + } + + Future fetchAndIngestConsole( + Uri consoleUrl, { + required WanderConsoleSource source, + }) async { + final wanderJsUrl = consoleUrl.resolve('wander.js'); + final now = DateTime.now(); + + try { + final result = await _runWanderJsFetch(( + token: ServicesBinding.rootIsolateToken!, + url: wanderJsUrl.toString(), + )); + + if (result == null) { + await _saveConsoleWithError(consoleUrl, wanderJsUrl, now, source); + return null; + } + + final existingUrls = await _db.wanderConsoleDao + .getExistingConsoleUrls(result.consoles) + .get(); + + await _db.transaction(() async { + await _db.wanderConsoleDao.upsertConsole( + WanderConsole( + url: consoleUrl, + wanderJsUrl: wanderJsUrl, + lastFetchedAt: now, + lastFetchFailed: false, + source: source, + createdAt: now, + ), + ); + + await _db.batch((batch) { + for (final neighborUrl in result.consoles) { + batch.insert( + _db.wanderConsoleNeighbors, + WanderConsoleNeighborsCompanion.insert( + sourceConsoleUrl: consoleUrl.toString(), + targetConsoleUrl: neighborUrl.toString(), + discoveredAt: Value(now), + ), + onConflict: DoNothing(), + ); + + if (!existingUrls.contains(neighborUrl)) { + batch.insert( + _db.wanderConsoles, + WanderConsolesCompanion.insert( + url: neighborUrl, + wanderJsUrl: neighborUrl.resolve('wander.js'), + discoveredFromUrl: Value(consoleUrl), + source: WanderConsoleSource.discovered, + createdAt: Value(now), + ), + onConflict: DoNothing(), + ); + } + } + + for (final pageUrl in result.pages) { + final itemId = uuid.v5(Namespace.url.value, pageUrl.toString()); + + batch.insert( + _db.smallWebItems, + SmallWebItemsCompanion.insert( + id: itemId, + url: pageUrl, + domain: pageUrl.host, + createdAt: Value(now), + updatedAt: Value(now), + ), + onConflict: DoUpdate( + (old) => SmallWebItemsCompanion(updatedAt: Value(now)), + target: [_db.smallWebItems.url], + ), + ); + + final membershipId = uuid.v5( + Namespace.url.value, + '${SmallWebSourceKind.wander.name}:$consoleUrl:$pageUrl', + ); + + batch.insert( + _db.smallWebMemberships, + SmallWebMembershipsCompanion.insert( + id: membershipId, + itemId: itemId, + sourceKind: SmallWebSourceKind.wander, + consoleUrl: Value(consoleUrl), + fetchedAt: Value(now), + ), + onConflict: DoUpdate( + (old) => SmallWebMembershipsCompanion(fetchedAt: Value(now)), + target: [_db.smallWebMemberships.id], + ), + ); + } + }); + }); + + return result; + } catch (e, st) { + logger.e( + 'Failed to fetch wander.js from $wanderJsUrl', + error: e, + stackTrace: st, + ); + await _saveConsoleWithError(consoleUrl, wanderJsUrl, now, source); + rethrow; + } + } + + /// Normalizes a user-input URL to a wander console URL. + /// + /// Accepts URLs like: + /// - `https://example.com/wander/` → kept as-is + /// - `https://example.com/wander` → trailing slash added + /// - `https://example.com` → `/wander/` appended + /// - `https://example.com/` → `wander/` appended + /// + /// Returns the normalized console URL (always ends with `/wander/`). + static Uri normalizeConsoleUrl(Uri url) { + var path = url.path; + + // Strip trailing wander.js if someone pasted the full JS URL + if (path.endsWith('/wander.js')) { + path = path.substring(0, path.length - 'wander.js'.length); + } + + // Ensure the path ends with /wander/ + if (!path.endsWith('/wander/')) { + if (path.endsWith('/wander')) { + path = '$path/'; + } else { + if (!path.endsWith('/')) { + path = '$path/'; + } + path = '${path}wander/'; + } + } + + return url.replace(path: path); + } + + /// Checks if a console URL already exists in the database. + Future consoleExists(Uri consoleUrl) async { + final console = await _db.wanderConsoleDao + .getConsole(consoleUrl) + .getSingleOrNull(); + + return console != null; + } + + /// Validates that a URL points to a valid wander console by fetching its + /// wander.js and checking it contains valid consoles or pages data. + /// + /// Returns the parsed [WanderJsResult] if valid, or throws with a + /// descriptive error message. + Future validateConsole(Uri consoleUrl) async { + final wanderJsUrl = consoleUrl.resolve('wander.js'); + + final result = await _runWanderJsFetch(( + token: ServicesBinding.rootIsolateToken!, + url: wanderJsUrl.toString(), + )); + + if (result == null) { + throw Exception('Could not fetch wander.js from $wanderJsUrl'); + } + + if (result.consoles.isEmpty && result.pages.isEmpty) { + throw Exception('The wander.js file contains no consoles or pages'); + } + + return result; + } + + /// Validates and adds a user-provided console URL. + /// + /// The URL is normalized, checked for duplicates, validated by fetching + /// wander.js, then ingested into the database. + /// + /// Returns the normalized console URL. + Future addConsoleFromUrl(Uri rawUrl) async { + final consoleUrl = normalizeConsoleUrl(rawUrl); + + if (await consoleExists(consoleUrl)) { + throw Exception('This console has already been added'); + } + + // Validate by fetching wander.js + await validateConsole(consoleUrl); + + // Now do the full ingest + await fetchAndIngestConsole(consoleUrl, source: WanderConsoleSource.manual); + + return consoleUrl; + } + + Future> getDiscoveredConsoleUrls() { + return _db.wanderConsoleDao.getDiscoveredConsoleUrls().get(); + } + + Future> getPagesForConsole(Uri consoleUrl) { + return _db.definitionsDrift + .getWanderPagesForConsole( + sourceKind: SmallWebSourceKind.wander, + consoleUrl: consoleUrl.toString(), + ) + .get(); + } + + Future _saveConsoleWithError( + Uri consoleUrl, + Uri wanderJsUrl, + DateTime now, + WanderConsoleSource source, + ) async { + await _db.wanderConsoleDao.upsertConsole( + WanderConsole( + url: consoleUrl, + wanderJsUrl: wanderJsUrl, + lastFetchedAt: now, + lastFetchFailed: true, + source: source, + createdAt: now, + ), + ); + } +} + +Future _runWanderJsFetch(_WanderJsFetchRequest request) { + return Isolate.run(_createWanderJsFetchTask(request)); +} + +Future Function() _createWanderJsFetchTask( + _WanderJsFetchRequest request, +) { + return () => _fetchAndParseWanderJs(request.token, Uri.parse(request.url)); +} + +Future _fetchAndParseWanderJs( + RootIsolateToken token, + Uri url, +) async { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + final client = http.Client(); + try { + final response = await client.get(url).timeout(const Duration(seconds: 15)); + + if (response.statusCode != 200) return null; + + return WanderJsResult.parse(response.body); + } finally { + client.close(); + } +} diff --git a/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.dart b/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.dart new file mode 100644 index 00000000..1f1a758c --- /dev/null +++ b/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.dart @@ -0,0 +1,91 @@ +/* + * 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 . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'small_web_mode_controller.g.dart'; + +@Riverpod(keepAlive: true) +class SmallWebModeController extends _$SmallWebModeController { + String? _smallWebTabId; + + @override + String? build() { + ref.listen(selectedTabProvider, (previous, next) { + _handleSelectedTabChange(next); + }); + + ref.listen(tabListProvider, (previous, next) { + if (_smallWebTabId != null && !next.value.contains(_smallWebTabId)) { + _smallWebTabId = null; + state = null; + } + }); + + return null; + } + + void _handleSelectedTabChange(String? selectedTabId) { + if (selectedTabId == _smallWebTabId && _smallWebTabId != null) { + state = _smallWebTabId; + } else if (state != null) { + state = null; + } + } + + Future enter() async { + if (state != null) return; + + if (_smallWebTabId != null && + ref.read(tabListProvider).value.contains(_smallWebTabId)) { + await ref.read(tabRepositoryProvider.notifier).selectTab(_smallWebTabId!); + return; + } + + final settings = ref.read(generalSettingsWithDefaultsProvider); + final tabMode = TabMode.fromTabType(settings.smallWebTabType); + + final newTabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab(tabMode: tabMode, selectTab: true); + + if (!ref.mounted) return; + + _smallWebTabId = newTabId; + state = newTabId; + + await ref.read(smallWebSessionControllerProvider.notifier).discover(); + } + + Future exit() async { + if (_smallWebTabId == null) return; + + final tabId = _smallWebTabId!; + _smallWebTabId = null; + state = null; + + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); + } +} diff --git a/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.g.dart b/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.g.dart new file mode 100644 index 00000000..7710699e --- /dev/null +++ b/app/lib/features/small_web/presentation/controllers/small_web_mode_controller.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'small_web_mode_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SmallWebModeController) +final smallWebModeControllerProvider = SmallWebModeControllerProvider._(); + +final class SmallWebModeControllerProvider + extends $NotifierProvider { + SmallWebModeControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'smallWebModeControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebModeControllerHash(); + + @$internal + @override + SmallWebModeController create() => SmallWebModeController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$smallWebModeControllerHash() => + r'26fb84c9ce60562738f7d9523f74aebfb19ad84b'; + +abstract class _$SmallWebModeController extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/small_web/presentation/controllers/small_web_session_controller.dart b/app/lib/features/small_web/presentation/controllers/small_web_session_controller.dart new file mode 100644 index 00000000..5f4d3d1a --- /dev/null +++ b/app/lib/features/small_web/presentation/controllers/small_web_session_controller.dart @@ -0,0 +1,327 @@ +// ignore_for_file: avoid_redundant_argument_values + +/* + * 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 . + */ +import 'dart:async'; +import 'dart:convert'; + +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/domain/providers.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'small_web_session_controller.g.dart'; + +@CopyWith() +@JsonSerializable() +class SmallWebSessionState with FastEquatable { + final SmallWebSourceKind sourceKind; + final KagiSmallWebMode? mode; + final String? currentCategory; + final String? currentItemId; + final Uri? currentItemUrl; + final Uri? currentConsoleUrl; + @JsonKey(includeToJson: false, includeFromJson: false) + final String? infoMessage; + + static KagiSmallWebMode? _defaultModeForSourceKind( + SmallWebSourceKind sourceKind, + ) { + return sourceKind == SmallWebSourceKind.wander + ? null + : KagiSmallWebMode.web; + } + + SmallWebSessionState({ + this.sourceKind = SmallWebSourceKind.kagi, + this.mode, + this.currentCategory, + this.currentItemId, + this.currentItemUrl, + this.currentConsoleUrl, + this.infoMessage, + }); + + factory SmallWebSessionState.fromJson(Map json) => + _$SmallWebSessionStateFromJson(json); + + Map toJson() => _$SmallWebSessionStateToJson(this); + + factory SmallWebSessionState.initial(SmallWebSourceKind sourceKind) { + return SmallWebSessionState( + sourceKind: sourceKind, + mode: _defaultModeForSourceKind(sourceKind), + ); + } + + factory SmallWebSessionState.forSourceKind({ + required SmallWebSourceKind sourceKind, + Uri? currentConsoleUrl, + }) { + return SmallWebSessionState( + sourceKind: sourceKind, + mode: _defaultModeForSourceKind(sourceKind), + currentConsoleUrl: sourceKind == SmallWebSourceKind.wander + ? currentConsoleUrl + : null, + ); + } + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + sourceKind, + mode, + currentCategory, + currentItemId, + currentItemUrl, + currentConsoleUrl, + infoMessage, + ]; +} + +@Riverpod(keepAlive: true) +class SmallWebSessionController extends _$SmallWebSessionController { + @override + AsyncValue build() { + final persistFuture = persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'SmallWebSessionState', + encode: (state) => jsonEncode( + (state.value ?? SmallWebSessionState.initial(SmallWebSourceKind.kagi)) + .toJson(), + ), + decode: (encoded) { + try { + final decoded = SmallWebSessionState.fromJson( + jsonDecode(encoded) as Map, + ); + + return AsyncData(decoded); + } catch (_) { + return AsyncData( + SmallWebSessionState.initial(SmallWebSourceKind.kagi), + ); + } + }, + ); + + listenSelf((previous, next) { + next.whenData((data) async { + if (data.currentItemUrl != null && + data.currentItemUrl != previous?.value?.currentItemUrl) { + final smallWebTabId = ref.read(smallWebModeControllerProvider); + var state = ref.read(tabStatesProvider)[smallWebTabId]; + + if (smallWebTabId != null) { + if (state == null) { + for (var i = 0; i < 25; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + + state = ref.read(tabStatesProvider)[smallWebTabId]; + + if (state != null) { + break; + } + } + } + + if (state == null) return; + + await ref + .read(tabSessionProvider(tabId: smallWebTabId).notifier) + .loadUrl(url: data.currentItemUrl!); + } + } + }); + }); + + if (persistFuture.future != null) { + return const AsyncLoading(); + } + + return AsyncData(SmallWebSessionState.initial(SmallWebSourceKind.kagi)); + } + + void setSourceKind(SmallWebSourceKind kind) { + state = AsyncData( + SmallWebSessionState.forSourceKind( + sourceKind: kind, + currentConsoleUrl: kind == SmallWebSourceKind.wander + ? state.value?.currentConsoleUrl + : null, + ), + ); + } + + void setMode(KagiSmallWebMode mode) { + if (state.hasValue) { + state = AsyncData( + state.requireValue.copyWith( + mode: mode, + currentCategory: null, + currentItemId: null, + currentItemUrl: null, + infoMessage: null, + ), + ); + } + } + + void setCategory(String? category) { + if (state.hasValue) { + state = AsyncData( + state.requireValue.copyWith( + currentCategory: category, + infoMessage: null, + ), + ); + } + } + + Future discover({bool forceNewConsole = false}) async { + if (state.isLoading) return; + if (!state.hasValue) return; + + final session = state.requireValue.copyWith(infoMessage: null); + + state = const AsyncLoading(); + + try { + final discoverService = await ref.read( + smallWebDiscoverServiceProvider.future, + ); + + if (session.sourceKind == SmallWebSourceKind.wander) { + final result = await discoverService.discoverWander( + currentConsoleUrl: session.currentConsoleUrl, + forceNewConsole: forceNewConsole, + ); + if (result != null) { + state = AsyncData( + session.copyWith( + currentItemId: result.item.id, + currentItemUrl: result.item.url, + currentConsoleUrl: result.consoleUrl, + ), + ); + + return; + } + } else { + final item = await discoverService.discoverKagi( + mode: session.mode!, + category: session.currentCategory, + ); + if (item != null) { + state = AsyncData( + session.copyWith(currentItemId: item.id, currentItemUrl: item.url), + ); + + return; + } + } + + state = AsyncData( + session.copyWith( + infoMessage: 'No new items found. Try a different mode or category.', + ), + ); + } catch (e, st) { + logger.e('Discovery failed', error: e, stackTrace: st); + state = AsyncError( + 'Discovery failed. Please try again.', + st, + ); + } + } + + void selectConsole(Uri consoleUrl) { + if (state.hasValue) { + state = AsyncData( + state.requireValue.copyWith( + currentConsoleUrl: consoleUrl, + currentItemId: null, + currentItemUrl: null, + ), + ); + } + } + + Future updateTitleFromTab(String title, {required Uri? tabUrl}) async { + if (state.hasValue) { + final session = state.requireValue; + + if (session.sourceKind != SmallWebSourceKind.wander) return; + + final itemId = session.currentItemId; + final itemUrl = session.currentItemUrl; + if (itemId == null || title.isEmpty) return; + if (tabUrl == null || itemUrl == null || tabUrl != itemUrl) return; + + final discoverService = await ref.read( + smallWebDiscoverServiceProvider.future, + ); + + await discoverService.updateItemTitle(itemId, title); + } + } + + Future revisit({ + required String itemId, + required Uri url, + required SmallWebSourceKind sourceKind, + required KagiSmallWebMode? mode, + Uri? consoleUrl, + }) async { + if (state.hasValue) { + final discoverService = await ref.read( + smallWebDiscoverServiceProvider.future, + ); + + await discoverService.recordVisit( + itemId: itemId, + sourceKind: sourceKind, + mode: mode, + consoleUrl: consoleUrl, + ); + + state = AsyncData( + state.requireValue.copyWith( + currentItemId: itemId, + currentItemUrl: url, + currentConsoleUrl: consoleUrl, + infoMessage: null, + ), + ); + } + } +} diff --git a/app/lib/features/small_web/presentation/controllers/small_web_session_controller.g.dart b/app/lib/features/small_web/presentation/controllers/small_web_session_controller.g.dart new file mode 100644 index 00000000..2f045ea7 --- /dev/null +++ b/app/lib/features/small_web/presentation/controllers/small_web_session_controller.g.dart @@ -0,0 +1,254 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'small_web_session_controller.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$SmallWebSessionStateCWProxy { + SmallWebSessionState sourceKind(SmallWebSourceKind sourceKind); + + SmallWebSessionState mode(KagiSmallWebMode? mode); + + SmallWebSessionState currentCategory(String? currentCategory); + + SmallWebSessionState currentItemId(String? currentItemId); + + SmallWebSessionState currentItemUrl(Uri? currentItemUrl); + + SmallWebSessionState currentConsoleUrl(Uri? currentConsoleUrl); + + SmallWebSessionState infoMessage(String? infoMessage); + + /// 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 `SmallWebSessionState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// SmallWebSessionState(...).copyWith(id: 12, name: "My name") + /// ``` + SmallWebSessionState call({ + SmallWebSourceKind sourceKind, + KagiSmallWebMode? mode, + String? currentCategory, + String? currentItemId, + Uri? currentItemUrl, + Uri? currentConsoleUrl, + String? infoMessage, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfSmallWebSessionState.copyWith(...)` or call `instanceOfSmallWebSessionState.copyWith.fieldName(value)` for a single field. +class _$SmallWebSessionStateCWProxyImpl + implements _$SmallWebSessionStateCWProxy { + const _$SmallWebSessionStateCWProxyImpl(this._value); + + final SmallWebSessionState _value; + + @override + SmallWebSessionState sourceKind(SmallWebSourceKind sourceKind) => + call(sourceKind: sourceKind); + + @override + SmallWebSessionState mode(KagiSmallWebMode? mode) => call(mode: mode); + + @override + SmallWebSessionState currentCategory(String? currentCategory) => + call(currentCategory: currentCategory); + + @override + SmallWebSessionState currentItemId(String? currentItemId) => + call(currentItemId: currentItemId); + + @override + SmallWebSessionState currentItemUrl(Uri? currentItemUrl) => + call(currentItemUrl: currentItemUrl); + + @override + SmallWebSessionState currentConsoleUrl(Uri? currentConsoleUrl) => + call(currentConsoleUrl: currentConsoleUrl); + + @override + SmallWebSessionState infoMessage(String? infoMessage) => + call(infoMessage: infoMessage); + + @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 `SmallWebSessionState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// SmallWebSessionState(...).copyWith(id: 12, name: "My name") + /// ``` + SmallWebSessionState call({ + Object? sourceKind = const $CopyWithPlaceholder(), + Object? mode = const $CopyWithPlaceholder(), + Object? currentCategory = const $CopyWithPlaceholder(), + Object? currentItemId = const $CopyWithPlaceholder(), + Object? currentItemUrl = const $CopyWithPlaceholder(), + Object? currentConsoleUrl = const $CopyWithPlaceholder(), + Object? infoMessage = const $CopyWithPlaceholder(), + }) { + return SmallWebSessionState( + sourceKind: + sourceKind == const $CopyWithPlaceholder() || sourceKind == null + ? _value.sourceKind + // ignore: cast_nullable_to_non_nullable + : sourceKind as SmallWebSourceKind, + mode: mode == const $CopyWithPlaceholder() + ? _value.mode + // ignore: cast_nullable_to_non_nullable + : mode as KagiSmallWebMode?, + currentCategory: currentCategory == const $CopyWithPlaceholder() + ? _value.currentCategory + // ignore: cast_nullable_to_non_nullable + : currentCategory as String?, + currentItemId: currentItemId == const $CopyWithPlaceholder() + ? _value.currentItemId + // ignore: cast_nullable_to_non_nullable + : currentItemId as String?, + currentItemUrl: currentItemUrl == const $CopyWithPlaceholder() + ? _value.currentItemUrl + // ignore: cast_nullable_to_non_nullable + : currentItemUrl as Uri?, + currentConsoleUrl: currentConsoleUrl == const $CopyWithPlaceholder() + ? _value.currentConsoleUrl + // ignore: cast_nullable_to_non_nullable + : currentConsoleUrl as Uri?, + infoMessage: infoMessage == const $CopyWithPlaceholder() + ? _value.infoMessage + // ignore: cast_nullable_to_non_nullable + : infoMessage as String?, + ); + } +} + +extension $SmallWebSessionStateCopyWith on SmallWebSessionState { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfSmallWebSessionState.copyWith(...)` or `instanceOfSmallWebSessionState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$SmallWebSessionStateCWProxy get copyWith => + _$SmallWebSessionStateCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SmallWebSessionState _$SmallWebSessionStateFromJson( + Map json, +) => SmallWebSessionState( + sourceKind: + $enumDecodeNullable(_$SmallWebSourceKindEnumMap, json['sourceKind']) ?? + SmallWebSourceKind.kagi, + mode: $enumDecodeNullable(_$KagiSmallWebModeEnumMap, json['mode']), + currentCategory: json['currentCategory'] as String?, + currentItemId: json['currentItemId'] as String?, + currentItemUrl: json['currentItemUrl'] == null + ? null + : Uri.parse(json['currentItemUrl'] as String), + currentConsoleUrl: json['currentConsoleUrl'] == null + ? null + : Uri.parse(json['currentConsoleUrl'] as String), +); + +Map _$SmallWebSessionStateToJson( + SmallWebSessionState instance, +) => { + 'sourceKind': _$SmallWebSourceKindEnumMap[instance.sourceKind]!, + 'mode': _$KagiSmallWebModeEnumMap[instance.mode], + 'currentCategory': instance.currentCategory, + 'currentItemId': instance.currentItemId, + 'currentItemUrl': instance.currentItemUrl?.toString(), + 'currentConsoleUrl': instance.currentConsoleUrl?.toString(), +}; + +const _$SmallWebSourceKindEnumMap = { + SmallWebSourceKind.kagi: 'kagi', + SmallWebSourceKind.wander: 'wander', +}; + +const _$KagiSmallWebModeEnumMap = { + KagiSmallWebMode.web: 'web', + KagiSmallWebMode.appreciated: 'appreciated', + KagiSmallWebMode.videos: 'videos', + KagiSmallWebMode.code: 'code', + KagiSmallWebMode.comics: 'comics', +}; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SmallWebSessionController) +final smallWebSessionControllerProvider = SmallWebSessionControllerProvider._(); + +final class SmallWebSessionControllerProvider + extends + $NotifierProvider< + SmallWebSessionController, + AsyncValue + > { + SmallWebSessionControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'smallWebSessionControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$smallWebSessionControllerHash(); + + @$internal + @override + SmallWebSessionController create() => SmallWebSessionController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>( + value, + ), + ); + } +} + +String _$smallWebSessionControllerHash() => + r'90680807b38de577e3322941b47792952d66428a'; + +abstract class _$SmallWebSessionController + extends $Notifier> { + AsyncValue build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref< + AsyncValue, + AsyncValue + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + AsyncValue + >, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_attribution_card.dart b/app/lib/features/small_web/presentation/widgets/small_web_attribution_card.dart new file mode 100644 index 00000000..743ff08b --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_attribution_card.dart @@ -0,0 +1,215 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; + +class SmallWebAttributionAction { + final String label; + final IconData icon; + final Uri uri; + + const SmallWebAttributionAction({ + required this.label, + required this.icon, + required this.uri, + }); +} + +class SmallWebAttributionData { + final IconData icon; + final String title; + final String? badgeLabel; + final String description; + final String attributionLine; + final String? metadataLine; + final List actions; + + const SmallWebAttributionData({ + required this.icon, + required this.title, + this.badgeLabel, + required this.description, + required this.attributionLine, + required this.actions, + this.metadataLine, + }); + + factory SmallWebAttributionData.forSelection({ + required SmallWebSourceKind sourceKind, + KagiSmallWebMode? mode, + }) { + return switch (sourceKind) { + SmallWebSourceKind.kagi => SmallWebAttributionData._forKagi( + mode ?? KagiSmallWebMode.web, + ), + SmallWebSourceKind.wander => SmallWebAttributionData._forWander(), + }; + } + + factory SmallWebAttributionData._forKagi(KagiSmallWebMode mode) { + final commonActions = [ + SmallWebAttributionAction( + label: 'Blog Post', + icon: Icons.article_outlined, + uri: Uri.https('blog.kagi.com', '/small-web'), + ), + SmallWebAttributionAction( + label: 'GitHub', + icon: Icons.code, + uri: Uri.https('github.com', '/kagisearch/smallweb'), + ), + ]; + + final description = switch (mode) { + KagiSmallWebMode.web => + 'Kagi Small Web surfaces recent posts from personal sites and blogs by individual authors across the small web.', + KagiSmallWebMode.appreciated => + 'This Kagi Small Web mode highlights appreciated posts from the small web as curated by the open-source project.', + KagiSmallWebMode.videos => + 'This Kagi Small Web mode focuses on video posts from smaller independent creators and curated channel seeds.', + KagiSmallWebMode.code => + 'This Kagi Small Web mode focuses on code-oriented posts from personal sites and other small web sources.', + KagiSmallWebMode.comics => + 'This Kagi Small Web mode focuses on comics and illustrated posts surfaced through the Small Web project.', + }; + + return SmallWebAttributionData( + icon: Icons.travel_explore, + title: 'Kagi Small Web', + badgeLabel: mode.label, + description: description, + attributionLine: 'By Kagi Search - open source under the MIT License.', + actions: [...commonActions], + ); + } + + factory SmallWebAttributionData._forWander() { + return SmallWebAttributionData( + icon: Icons.dns, + title: 'Wander', + description: + 'Wander is a network of personal websites connected through shared consoles that help people browse pages across the wider Wander community.', + attributionLine: 'By Susam Pal - open source under the MIT License.', + actions: [ + SmallWebAttributionAction( + label: 'Project', + icon: Icons.public, + uri: Uri.https('codeberg.org', '/susam/wander'), + ), + SmallWebAttributionAction( + label: 'Setup your Console', + icon: Icons.forum_outlined, + uri: Uri.https('codeberg.org', '/susam/wander#install'), + ), + ], + ); + } +} + +class SmallWebAttributionCard extends StatelessWidget { + final SmallWebAttributionData data; + final ValueChanged onOpenUri; + final bool compact; + + const SmallWebAttributionCard({ + super.key, + required this.data, + required this.onOpenUri, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final headerStyle = compact + ? theme.textTheme.titleSmall + : theme.textTheme.titleMedium; + final bodyStyle = compact + ? theme.textTheme.bodySmall + : theme.textTheme.bodyMedium; + final spacing = compact ? 8.0 : 12.0; + + return Card( + color: colorScheme.surfaceContainerHigh, + child: Padding( + padding: EdgeInsets.all(compact ? 12 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + data.icon, + size: compact ? 20 : 22, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + data.title, + style: headerStyle?.copyWith(color: colorScheme.primary), + ), + ), + if (data.badgeLabel != null) + Chip( + visualDensity: VisualDensity.compact, + label: Text(data.badgeLabel!), + ), + ], + ), + SizedBox(height: spacing), + Text(data.description, style: bodyStyle), + SizedBox(height: spacing), + Text( + data.attributionLine, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (data.metadataLine case final metadataLine?) ...[ + const SizedBox(height: 4), + Text( + metadataLine, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + SizedBox(height: spacing), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final action in data.actions) + ActionChip( + avatar: Icon(action.icon, size: 18), + label: Text(action.label), + onPressed: () => onOpenUri(action.uri), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_attribution_navigation.dart b/app/lib/features/small_web/presentation/widgets/small_web_attribution_navigation.dart new file mode 100644 index 00000000..eedc9306 --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_attribution_navigation.dart @@ -0,0 +1,40 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; + +Future openSmallWebAttributionUri(BuildContext context, Uri uri) async { + final container = ProviderScope.containerOf(context, listen: false); + final router = await container.read(routerProvider.future); + + if (context.mounted) { + Navigator.of(context).pop(); + } + + await container + .read(tabRepositoryProvider.notifier) + .addTab(url: uri, tabMode: TabMode.regular, selectTab: true); + + router.go(const BrowserRoute().location); +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_bottom_bar.dart b/app/lib/features/small_web/presentation/widgets/small_web_bottom_bar.dart new file mode 100644 index 00000000..0a0bde62 --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_bottom_bar.dart @@ -0,0 +1,262 @@ +/* + * 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 . + */ +import 'dart:math'; + +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +// What words does the wanderer whisper? +const _incantations = [ + 'Discover', + 'Explore', + 'Uncover', + 'Venture', + 'Stumble', + 'Unearth', + 'Surface', + 'Traverse', + 'Journey', + 'Wander', + 'Drift', + 'Roam', + 'Seek', + 'Delve', + 'Forage', + 'Glimpse', + 'Meander', + 'Rummage', + 'Saunter', + 'Unveil', + 'Excavate', + 'Fathom', + 'Unravel', + 'Summon', + 'Conjure', + 'Invoke', + 'Divine', + 'Beckon', + 'Emerge', + 'Ascend', + 'Leap', + 'Untangle', + 'Illuminate', + 'Whisper', + 'Ponder', + 'Stray', + 'Ramble', + 'Chart', + 'Prowl', + 'Unwind', + 'Foray', + 'Plunge', + 'Scout', + 'Pilgrimage', + 'Gallivant', + 'Sift', + 'Decode', + 'Unfurl', + 'Kindle', + 'Peruse', + 'Dabble', + 'Peer', + 'Freefall', + 'Vault', + 'Burrow', + 'Glean', + 'Transmute', + 'Decipher', + 'Unmask', + 'Plumb', + 'Unseal', + 'Ignite', + 'Evoke', + 'Manifest', + 'Enchant', + 'Entrance', + 'Lure', + 'Coax', + 'Entice', + 'Eclipse', + 'Transcend', + 'Migrate', + 'Flit', + 'Tumble', + 'Cascade', + 'Zigzag', + 'Spiral', + 'Orbit', + 'Converge', + 'Gravitate', + 'Bloom', + 'Unfold', + 'Blossom', + 'Awaken', + 'Weave', + 'Channel', + 'Envision', + 'Muse', + 'Wonder', + 'Marvel', + 'Dream', + 'Reflect', + 'Behold', + 'Witness', + 'Unlock', + 'Pry', + 'Release', + 'Glide', + 'Soar', + 'Slip', + 'Navigate', + 'Bound', + 'Sweep', + 'Phase', + 'Warp', + 'Shift', + 'Blink', + 'Tiptoe', + 'Breeze', + 'Descend', + 'Immerse', + 'Wade', + 'Launch', + 'Spark', + 'Morph', +]; + +final _random = Random(); + +class SmallWebBottomBar extends HookConsumerWidget { + final bool isLoading; + final Uri? currentTabUrl; + final String? currentTabTitle; + final VoidCallback onDiscover; + final VoidCallback onMenuTap; + final VoidCallback onExit; + + const SmallWebBottomBar({ + super.key, + required this.isLoading, + required this.currentTabUrl, + required this.currentTabTitle, + required this.onDiscover, + required this.onMenuTap, + required this.onExit, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = useState(_incantations.first); + + final tabUrl = currentTabUrl; + final bookmarkable = tabUrl != null; + final existingGuids = ref + .watch( + bookmarksRepositoryProvider.select( + (async) => EquatableValue( + bookmarkable + ? bookmarkGuidsForUrl(async.value, tabUrl) + : const [], + ), + ), + ) + .value; + + final isBookmarked = existingGuids.isNotEmpty; + + return SizedBox( + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Menu', + onPressed: onMenuTap, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FilledButton.icon( + onPressed: isLoading + ? null + : () { + onDiscover(); + label.value = + _incantations[_random.nextInt( + _incantations.length, + )]; + }, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.explore, size: 20), + label: Text(label.value), + ), + ), + ), + IconButton( + icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border), + tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark', + onPressed: !bookmarkable + ? null + : () async { + if (isBookmarked) { + for (final guid in existingGuids) { + await ref + .read(bookmarksRepositoryProvider.notifier) + .delete(guid); + } + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Bookmark removed'); + } + } else { + await ref + .read(bookmarksRepositoryProvider.notifier) + .addBookmark( + parentGuid: BookmarkRoot.mobile.id, + url: tabUrl, + title: currentTabTitle ?? tabUrl.host, + ); + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Bookmark added'); + } + } + }, + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Exit Small Web', + onPressed: onExit, + ), + ], + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_browser_overlay.dart b/app/lib/features/small_web/presentation/widgets/small_web_browser_overlay.dart new file mode 100644 index 00000000..a8f3958c --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_browser_overlay.dart @@ -0,0 +1,79 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_bottom_bar.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_menu_sheet.dart'; + +class SmallWebBrowserOverlay extends HookConsumerWidget { + const SmallWebBrowserOverlay({super.key}); + + static const barHeight = 56.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessionAsync = ref.watch(smallWebSessionControllerProvider); + final selectedTabId = ref.watch(selectedTabProvider); + + final tabState = ref.watch(tabStateProvider(selectedTabId)); + final tabUrl = tabState?.url; + + ref.listen( + tabStateProvider(selectedTabId).select((value) => value?.title), + (prev, title) async { + if (title != null && title.isNotEmpty && prev != title) { + await ref + .read(smallWebSessionControllerProvider.notifier) + .updateTitleFromTab(title, tabUrl: tabUrl); + } + }, + ); + + ref.listen(smallWebSessionControllerProvider, (prev, next) { + final error = next.asError?.error; + final previousError = prev?.asError?.error; + + if (error != null && error != previousError && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + }); + + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: SmallWebBottomBar( + isLoading: sessionAsync.isLoading, + currentTabUrl: tabUrl, + currentTabTitle: tabState?.titleOrAuthority, + onDiscover: () => + ref.read(smallWebSessionControllerProvider.notifier).discover(), + onMenuTap: () => showSmallWebMenuSheet(context), + onExit: () => ref.read(smallWebModeControllerProvider.notifier).exit(), + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_history_list.dart b/app/lib/features/small_web/presentation/widgets/small_web_history_list.dart new file mode 100644 index 00000000..adccca58 --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_history_list.dart @@ -0,0 +1,309 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/small_web/data/database/definitions.drift.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/providers.dart'; +import 'package:weblibre/features/small_web/domain/providers.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class SmallWebHistoryHeader extends ConsumerWidget { + final SmallWebSourceKind sourceKind; + final KagiSmallWebMode? mode; + + const SmallWebHistoryHeader({ + super.key, + required this.sourceKind, + required this.mode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final modeLabel = mode?.label; + + return Row( + children: [ + Text('Recent Discoveries', style: theme.textTheme.titleSmall), + const Spacer(), + PopupMenuButton<_ClearAction>( + icon: Icon( + Icons.more_vert, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + offset: const Offset(0, 36), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onSelected: (action) async { + switch (action) { + case _ClearAction.clearMode: + await ref + .read(smallWebDatabaseProvider) + .smallWebVisitDao + .deleteVisitsBySourceAndMode( + sourceKind: sourceKind, + mode: mode, + ); + case _ClearAction.clearAll: + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear all discoveries?'), + content: const Text( + 'This will permanently remove all recent discovery history across every mode and source.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear All'), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(smallWebDatabaseProvider) + .smallWebVisitDao + .deleteAllVisits(); + } + } + }, + itemBuilder: (context) => [ + if (modeLabel != null) + PopupMenuItem( + value: _ClearAction.clearMode, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_sweep, size: 20), + title: Text('Clear $modeLabel'), + ), + ), + PopupMenuItem( + value: _ClearAction.clearAll, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.delete_forever, + size: 20, + color: colorScheme.error, + ), + title: Text( + 'Clear all discoveries', + style: TextStyle(color: colorScheme.error), + ), + ), + ), + ], + ), + ], + ); + } +} + +enum _ClearAction { clearMode, clearAll } + +class SmallWebHistoryList extends HookConsumerWidget { + static const _initialCount = 10; + + final SmallWebSourceKind sourceKind; + final KagiSmallWebMode? mode; + + const SmallWebHistoryList({ + super.key, + required this.sourceKind, + required this.mode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final visitsAsync = ref.watch( + smallWebRecentVisitsProvider(sourceKind, mode), + ); + + final expanded = useState(false); + + return visitsAsync.when( + data: (visits) { + if (visits.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + 'No discoveries yet.\nTap Discover to start exploring!', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final hasMore = visits.length > _initialCount; + final visibleCount = expanded.value || !hasMore + ? visits.length + : _initialCount; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: visibleCount, + itemBuilder: (context, index) { + final visit = visits[index]; + return _HistoryListItem( + visit: visit, + sourceKind: sourceKind, + mode: mode, + ); + }, + ), + if (hasMore && !expanded.value) + TextButton( + onPressed: () => expanded.value = true, + child: Text('Show ${visits.length - _initialCount} more'), + ), + ], + ); + }, + error: (error, _) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center(child: Text('Failed to load history: $error')), + ), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } +} + +class _HistoryListItem extends ConsumerWidget { + final GetRecentVisitsResult visit; + final SmallWebSourceKind sourceKind; + final KagiSmallWebMode? mode; + + const _HistoryListItem({ + required this.visit, + required this.sourceKind, + required this.mode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + const borderRadius = BorderRadius.all(Radius.circular(12)); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 3), + decoration: const BoxDecoration(borderRadius: borderRadius), + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: borderRadius, + onTap: () async { + await ref + .read(smallWebSessionControllerProvider.notifier) + .revisit( + itemId: visit.itemId, + url: visit.url, + sourceKind: sourceKind, + mode: mode, + consoleUrl: visit.consoleUrl, + ); + if (context.mounted) Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 10, bottom: 10), + child: Row( + children: [ + UrlIcon([visit.url], iconSize: 32), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + visit.title ?? visit.domain, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + UriBreadcrumb( + uri: visit.url, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + onPressed: () async { + await ref + .read(smallWebDatabaseProvider) + .smallWebVisitDao + .deleteVisitById(visit.id); + }, + icon: Icon( + Icons.close, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart b/app/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart new file mode 100644 index 00000000..4cc0ee4f --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_menu_sheet.dart @@ -0,0 +1,897 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_category.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart'; +import 'package:weblibre/features/small_web/data/providers.dart'; +import 'package:weblibre/features/small_web/domain/providers.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_attribution_card.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_attribution_navigation.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_history_list.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_mode_chips.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/wander_console_sheet.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +Future showSmallWebMenuSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => const _SmallWebMenuSheet(), + ); +} + +class _SmallWebMenuSheet extends ConsumerWidget { + const _SmallWebMenuSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessionAsync = ref.watch(smallWebSessionControllerProvider); + final colorScheme = Theme.of(context).colorScheme; + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + height: 4, + width: 40, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.explore, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Small Web', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + sessionAsync.when( + data: (session) => _SourceKindChip( + sourceKind: session.sourceKind, + onChanged: (kind) { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setSourceKind(kind); + // await notifier.discover(); + }, + ), + loading: () => _SourceKindChip( + sourceKind: SmallWebSourceKind.kagi, + onChanged: (_) {}, + ), + error: (_, _) => IconButton( + onPressed: ref + .read(smallWebSessionControllerProvider.notifier) + .discover, + icon: const Icon(Icons.refresh), + tooltip: 'Retry', + ), + ), + ], + ), + ), + Expanded( + child: sessionAsync.when( + data: (session) => _SmallWebMenuContent( + scrollController: scrollController, + session: session, + ), + loading: () => + _SmallWebMenuLoading(scrollController: scrollController), + error: (error, _) => _SmallWebMenuError( + scrollController: scrollController, + error: error, + onRetry: ref + .read(smallWebSessionControllerProvider.notifier) + .discover, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _SourceKindChip extends StatelessWidget { + final SmallWebSourceKind sourceKind; + final ValueChanged onChanged; + + const _SourceKindChip({required this.sourceKind, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return PopupMenuButton( + initialValue: sourceKind, + onSelected: (kind) { + if (kind != sourceKind) onChanged(kind); + }, + offset: const Offset(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + itemBuilder: (context) => [ + for (final kind in SmallWebSourceKind.values) + PopupMenuItem( + value: kind, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(kind.icon, size: 20), + title: Text(kind.label), + subtitle: Text( + kind.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: kind == sourceKind + ? Icon(Icons.check, size: 20, color: colorScheme.primary) + : null, + ), + ), + ], + child: Chip( + avatar: Icon(sourceKind.icon, size: 18), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(sourceKind.label), + const SizedBox(width: 2), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + visualDensity: VisualDensity.compact, + ), + ); + } +} + +class _SmallWebMenuContent extends ConsumerWidget { + final ScrollController scrollController; + final SmallWebSessionState session; + + const _SmallWebMenuContent({ + required this.scrollController, + required this.session, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + if (session.sourceKind == SmallWebSourceKind.kagi) ...[ + SmallWebModeChips( + currentMode: session.mode, + isLoading: false, + onModeSelected: (mode) { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setMode(mode); + // await notifier.discover(); + }, + ), + const SizedBox(height: 4), + const Divider(height: 1), + ], + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: _buildContentForMode(context, ref, scrollController), + ), + ), + ], + ); + } + + Widget _buildContentForMode( + BuildContext context, + WidgetRef ref, + ScrollController scrollController, + ) { + if (session.sourceKind == SmallWebSourceKind.kagi && + session.mode == KagiSmallWebMode.web) { + return _WebCategoriesPanel( + key: const ValueKey('web_panel'), + scrollController: scrollController, + session: session, + ); + } + + if (session.sourceKind == SmallWebSourceKind.kagi && + session.mode != null && + session.mode != KagiSmallWebMode.web) { + return _ModeContextPanel( + key: ValueKey('mode_${session.mode!.name}'), + scrollController: scrollController, + session: session, + mode: session.mode!, + ); + } + + return _DefaultContentPanel( + key: const ValueKey('default_panel'), + scrollController: scrollController, + session: session, + ); + } +} + +class _WebCategoriesPanel extends ConsumerWidget { + final ScrollController scrollController; + final SmallWebSessionState session; + + const _WebCategoriesPanel({ + super.key, + required this.scrollController, + required this.session, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final categoriesAsync = ref.watch(kagiCategoriesProvider); + + return categoriesAsync.when( + data: (kagiCategories) => ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Refine Category', style: theme.textTheme.titleSmall), + FilterChip( + label: const Text('All'), + selected: session.currentCategory == null, + showCheckmark: false, + onSelected: (_) async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setCategory(null); + await notifier.discover(); + }, + ), + ], + ), + const SizedBox(height: 8), + for (final MapEntry(key: groupName, value: slugs) + in kagiCategories.groups.entries) ...[ + _SectionHeader(title: groupName), + const SizedBox(height: 6), + _CategoryGrid( + categories: kagiCategories.categories, + slugs: slugs, + currentCategory: session.currentCategory, + onCategorySelected: (slug) async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.setCategory( + session.currentCategory == slug ? null : slug, + ); + await notifier.discover(); + }, + ), + const SizedBox(height: 10), + ], + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + compact: true, + ), + const SizedBox(height: 8), + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList( + sourceKind: session.sourceKind, + mode: session.mode, + ), + ], + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + +class _ModeContextPanel extends ConsumerWidget { + final ScrollController scrollController; + final SmallWebSessionState session; + final KagiSmallWebMode mode; + + const _ModeContextPanel({ + super.key, + required this.scrollController, + required this.session, + required this.mode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + + final (IconData icon, String description) = switch (mode) { + KagiSmallWebMode.appreciated => ( + Icons.volunteer_activism, + 'Browse highly curated, user-appreciated links from the small web community.', + ), + KagiSmallWebMode.videos => ( + Icons.video_library, + 'Discover video content from independent creators across the small web.', + ), + KagiSmallWebMode.code => ( + Icons.data_object, + 'Find code snippets, repositories, and technical articles from personal sites.', + ), + KagiSmallWebMode.comics => ( + Icons.auto_stories, + 'Explore indie comics and web-graphics from independent illustrators.', + ), + KagiSmallWebMode.web => (Icons.language, ''), + }; + + return ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + const SizedBox(height: 16), + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 36, color: colorScheme.onPrimaryContainer), + ), + ), + const SizedBox(height: 12), + Text( + 'Searching ${mode.label}', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + description, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + compact: true, + ), + const SizedBox(height: 8), + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode), + ], + ); + } +} + +class _DefaultContentPanel extends ConsumerWidget { + final ScrollController scrollController; + final SmallWebSessionState session; + + const _DefaultContentPanel({ + super.key, + required this.scrollController, + required this.session, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + if (session.infoMessage != null) ...[ + _InfoMessageCard(message: session.infoMessage!), + const SizedBox(height: 8), + ], + SmallWebAttributionCard( + data: SmallWebAttributionData.forSelection( + sourceKind: session.sourceKind, + mode: session.mode, + ), + onOpenUri: (uri) => openSmallWebAttributionUri(context, uri), + ), + const SizedBox(height: 12), + if (session.sourceKind == SmallWebSourceKind.wander) ...[ + _WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: () async { + Navigator.of(context).pop(); + await showWanderConsoleSheet(context); + }, + icon: const Icon(Icons.dns, size: 18), + label: const Text('Browse Consoles'), + ), + const SizedBox(height: 12), + ], + const _DiscoverButton(), + const SizedBox(height: 12), + SmallWebHistoryHeader( + sourceKind: session.sourceKind, + mode: session.mode, + ), + const SizedBox(height: 4), + SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode), + ], + ); + } +} + +// --- Shared sub-widgets --- + +class _DiscoverButton extends ConsumerWidget { + const _DiscoverButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = ref.watch( + smallWebSessionControllerProvider.select((s) => s.isLoading), + ); + + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isLoading + ? null + : () async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + + if (context.mounted) Navigator.pop(context); + await notifier.discover(); + }, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.explore, size: 20), + label: const Text('Discover'), + ), + ); + } +} + +class _InfoMessageCard extends StatelessWidget { + final String message; + + const _InfoMessageCard({required this.message}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + color: colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + + const _SectionHeader({required this.title}); + + @override + Widget build(BuildContext context) { + return Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _CategoryGrid extends StatelessWidget { + final Map categories; + final List slugs; + final String? currentCategory; + final ValueChanged onCategorySelected; + + const _CategoryGrid({ + required this.categories, + required this.slugs, + required this.currentCategory, + required this.onCategorySelected, + }); + + @override + Widget build(BuildContext context) { + final visibleSlugs = slugs.where((s) => categories.containsKey(s)).toList(); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3.5, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: visibleSlugs.length, + itemBuilder: (context, index) { + final slug = visibleSlugs[index]; + final cat = categories[slug]!; + return _CategoryTile( + label: cat.label, + emoji: cat.emoji, + isSelected: currentCategory == slug, + onTap: () => onCategorySelected(slug), + ); + }, + ); + } +} + +class _CategoryTile extends StatelessWidget { + final String label; + final String emoji; + final bool isSelected; + final VoidCallback onTap; + + const _CategoryTile({ + required this.label, + required this.emoji, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outlineVariant, + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ); + } +} + +// --- Loading / Error states --- + +class _SmallWebMenuLoading extends StatelessWidget { + final ScrollController scrollController; + + const _SmallWebMenuLoading({required this.scrollController}); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Mode chips skeleton + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Bone( + width: 60, + height: 32, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + SizedBox(width: 8), + Bone( + width: 70, + height: 32, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + SizedBox(width: 8), + Bone( + width: 65, + height: 32, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: const [ + Bone.text(words: 2), + SizedBox(height: 8), + _SkeletonHistoryItem(), + _SkeletonHistoryItem(), + _SkeletonHistoryItem(), + ], + ), + ), + ], + ), + ); + } +} + +class _SkeletonHistoryItem extends StatelessWidget { + const _SkeletonHistoryItem(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 3), + child: Padding( + padding: EdgeInsets.only(left: 12, top: 10, bottom: 10), + child: Row( + children: [ + Bone.circle(size: 32), + SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Bone.text(words: 2), + SizedBox(height: 3), + Bone.text(words: 3, fontSize: 12), + ], + ), + ), + SizedBox(width: 48), + ], + ), + ), + ); + } +} + +class _SmallWebMenuError extends StatelessWidget { + final ScrollController scrollController; + final Object error; + final VoidCallback onRetry; + + const _SmallWebMenuError({ + required this.scrollController, + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + SizedBox( + height: 240, + child: FailureWidget( + title: 'Small Web unavailable', + exception: error, + onRetry: onRetry, + ), + ), + ], + ); + } +} + +// --- Wander console card --- + +class _WanderConsoleCard extends ConsumerWidget { + final Uri? currentConsoleUrl; + + const _WanderConsoleCard({required this.currentConsoleUrl}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + if (currentConsoleUrl == null) { + return Card( + color: colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.dns_outlined, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'No console selected', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + final statsAsync = ref.watch( + wanderConsoleStatsProvider(currentConsoleUrl!), + ); + + return Card( + color: colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.dns, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + currentConsoleUrl!.host, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + statsAsync.when( + data: (stats) => Text( + '${stats.linkedConsoles} linked consoles \u00b7 ${stats.pages} pages', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart b/app/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart new file mode 100644 index 00000000..961a479c --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/small_web_mode_chips.dart @@ -0,0 +1,107 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart'; +import 'package:weblibre/features/small_web/domain/providers.dart'; + +class SmallWebModeChips extends ConsumerWidget { + final KagiSmallWebMode? currentMode; + final bool isLoading; + final ValueChanged onModeSelected; + + const SmallWebModeChips({ + super.key, + required this.currentMode, + required this.isLoading, + required this.onModeSelected, + }); + + static String _formatCount(int count) { + if (count >= 1000) { + final k = count / 1000; + return k == k.roundToDouble() + ? '${k.round()}k' + : '${k.toStringAsFixed(1)}k'; + } + return count.toString(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final countsAsync = ref.watch(smallWebAllModeItemCountsProvider); + final counts = countsAsync.value ?? {}; + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: 48, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: KagiSmallWebMode.values.length, + itemBuilder: (context, index) { + final mode = KagiSmallWebMode.values[index]; + final isSelected = currentMode == mode; + final count = counts[mode]; + return Padding( + padding: const EdgeInsets.only(right: 8, top: 4), + child: ChoiceChip( + avatar: Icon(mode.icon, size: 18), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(mode.label), + if (count != null && count > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.15) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _formatCount(count), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + selected: isSelected, + showCheckmark: false, + onSelected: isLoading ? null : (_) => onModeSelected(mode), + ), + ); + }, + ), + ); + } +} diff --git a/app/lib/features/small_web/presentation/widgets/wander_console_sheet.dart b/app/lib/features/small_web/presentation/widgets/wander_console_sheet.dart new file mode 100644 index 00000000..10edc222 --- /dev/null +++ b/app/lib/features/small_web/presentation/widgets/wander_console_sheet.dart @@ -0,0 +1,668 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/small_web/domain/providers.dart'; +import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart'; +import 'package:weblibre/features/small_web/presentation/widgets/small_web_menu_sheet.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/sliding_pill_toggle.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/form_validators.dart'; + +Future showWanderConsoleSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => const _WanderConsoleSheet(), + ); +} + +class _WanderConsoleSheet extends HookConsumerWidget { + const _WanderConsoleSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sessionAsync = ref.watch(smallWebSessionControllerProvider); + final colorScheme = Theme.of(context).colorScheme; + + final searchController = useTextEditingController(); + final searchQuery = useListenableSelector( + searchController, + () => searchController.text.toLowerCase(), + ); + final showAllConsoles = useState(null); + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + height: 4, + width: 40, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + IconButton( + onPressed: () async { + Navigator.of(context).pop(); + await showSmallWebMenuSheet(context); + }, + icon: const Icon(Icons.arrow_back), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + Text( + 'Select Console', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + FilledButton.tonalIcon( + onPressed: sessionAsync.isLoading || sessionAsync.hasError + ? null + : () async { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + + await notifier.discover(forceNewConsole: true); + }, + icon: const Icon(Icons.shuffle, size: 18), + label: const Text('Random'), + ), + ], + ), + ), + Expanded( + child: sessionAsync.when( + data: (session) { + final currentConsoleUrl = session.currentConsoleUrl; + final effectiveShowAllConsoles = + showAllConsoles.value ?? currentConsoleUrl == null; + + return _WanderConsoleSheetContent( + session: session, + searchController: searchController, + searchQuery: searchQuery, + scrollController: scrollController, + showAllConsoles: effectiveShowAllConsoles, + onToggleAllConsoles: (value) { + showAllConsoles.value = value; + }, + onAddConsole: () => _showAddConsoleDialog(context, ref), + ); + }, + loading: () => _WanderConsoleSheetLoading( + scrollController: scrollController, + ), + error: (error, _) => _WanderConsoleSheetError( + scrollController: scrollController, + error: error, + onRetry: ref + .read(smallWebSessionControllerProvider.notifier) + .discover, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _WanderConsoleSheetContent extends StatelessWidget { + final SmallWebSessionState session; + final TextEditingController searchController; + final String searchQuery; + final ScrollController scrollController; + final bool showAllConsoles; + final ValueChanged onToggleAllConsoles; + final VoidCallback onAddConsole; + + const _WanderConsoleSheetContent({ + required this.session, + required this.searchController, + required this.searchQuery, + required this.scrollController, + required this.showAllConsoles, + required this.onToggleAllConsoles, + required this.onAddConsole, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final currentConsoleUrl = session.currentConsoleUrl; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: TextField( + controller: searchController, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Filter consoles...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: searchQuery.isNotEmpty + ? IconButton( + onPressed: searchController.clear, + icon: const Icon(Icons.clear, size: 20), + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + ), + ), + ), + if (currentConsoleUrl != null) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SlidingPillToggle( + selectedIndex: showAllConsoles ? 1 : 0, + labels: const ['Linked', 'All'], + onChanged: (index) => onToggleAllConsoles(index == 1), + ), + ), + ], + const SizedBox(height: 8), + const Divider(height: 1), + Expanded( + child: Stack( + children: [ + if (showAllConsoles) + _AllConsoleList( + searchQuery: searchQuery, + scrollController: scrollController, + selectedConsoleUrl: currentConsoleUrl, + isLoading: false, + ) + else + currentConsoleUrl == null + ? const Center( + child: Text('No console selected yet. Press Discover.'), + ) + : _LinkedConsoleList( + consoleUrl: currentConsoleUrl, + searchQuery: searchQuery, + scrollController: scrollController, + selectedConsoleUrl: currentConsoleUrl, + isLoading: false, + ), + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton.small( + onPressed: onAddConsole, + tooltip: 'Add console by URL', + child: const Icon(Icons.add), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _WanderConsoleSheetLoading extends StatelessWidget { + final ScrollController scrollController; + + const _WanderConsoleSheetLoading({required this.scrollController}); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: const [ + TextField( + decoration: InputDecoration(hintText: 'Filter consoles...'), + ), + SizedBox(height: 12), + _SkeletonConsoleTile(), + _SkeletonConsoleTile(), + _SkeletonConsoleTile(), + ], + ), + ); + } +} + +class _SkeletonConsoleTile extends StatelessWidget { + const _SkeletonConsoleTile(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 3), + child: Padding( + padding: EdgeInsets.only(left: 12, top: 10, bottom: 10, right: 12), + child: Row( + children: [ + Bone.circle(size: 32), + SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Bone.text(words: 2), + SizedBox(height: 3), + Bone.text(words: 1, fontSize: 12), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _WanderConsoleSheetError extends StatelessWidget { + final ScrollController scrollController; + final Object error; + final VoidCallback onRetry; + + const _WanderConsoleSheetError({ + required this.scrollController, + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + SizedBox( + height: 240, + child: FailureWidget( + title: 'Could not load Small Web session', + exception: error, + onRetry: onRetry, + ), + ), + ], + ); + } +} + +class _LinkedConsoleList extends ConsumerWidget { + final Uri consoleUrl; + final String searchQuery; + final ScrollController scrollController; + final Uri? selectedConsoleUrl; + final bool isLoading; + + const _LinkedConsoleList({ + required this.consoleUrl, + required this.searchQuery, + required this.scrollController, + required this.selectedConsoleUrl, + required this.isLoading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final neighborsAsync = ref.watch( + wanderNeighborConsolesProvider(consoleUrl), + ); + + return neighborsAsync.when( + data: (consoles) { + final filtered = searchQuery.isEmpty + ? consoles + : consoles + .where( + (c) => + c.url.host.toLowerCase().contains(searchQuery) || + c.url.toString().toLowerCase().contains(searchQuery), + ) + .toList(); + if (filtered.isEmpty) { + return Center( + child: Text( + searchQuery.isEmpty + ? 'No linked consoles found.' + : 'No consoles matching "$searchQuery".', + ), + ); + } + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: filtered.length, + itemBuilder: (context, index) { + final console = filtered[index]; + return _ConsoleListTile( + url: console.url, + pageCount: console.pageCount, + selectedConsoleUrl: selectedConsoleUrl, + isLoading: isLoading, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const Center(child: Text('Failed to load consoles.')), + ); + } +} + +class _AllConsoleList extends ConsumerWidget { + final String searchQuery; + final ScrollController scrollController; + final Uri? selectedConsoleUrl; + final bool isLoading; + + const _AllConsoleList({ + required this.searchQuery, + required this.scrollController, + required this.selectedConsoleUrl, + required this.isLoading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final consolesAsync = ref.watch(wanderAllConsolesProvider(searchQuery)); + + return consolesAsync.when( + data: (consoles) { + if (consoles.isEmpty) { + return Center( + child: Text( + searchQuery.isEmpty + ? 'No consoles discovered yet.' + : 'No consoles matching "$searchQuery".', + ), + ); + } + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: consoles.length, + itemBuilder: (context, index) { + final console = consoles[index]; + return _ConsoleListTile( + url: console.url, + pageCount: console.pageCount, + selectedConsoleUrl: selectedConsoleUrl, + isLoading: isLoading, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const Center(child: Text('Failed to load consoles.')), + ); + } +} + +class _ConsoleListTile extends ConsumerWidget { + final Uri url; + final int pageCount; + final Uri? selectedConsoleUrl; + final bool isLoading; + + const _ConsoleListTile({ + required this.url, + required this.pageCount, + required this.selectedConsoleUrl, + required this.isLoading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final isSelected = selectedConsoleUrl == url; + + const borderRadius = BorderRadius.all(Radius.circular(12)); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 3), + decoration: BoxDecoration( + borderRadius: borderRadius, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.4) + : null, + ), + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: borderRadius, + onTap: isLoading + ? null + : () { + final notifier = ref.read( + smallWebSessionControllerProvider.notifier, + ); + notifier.selectConsole(url); + + if (context.mounted) { + Navigator.of(context).pop(); + } + + // await notifier.discover(); + }, + child: Padding( + padding: const EdgeInsets.only( + left: 12, + top: 10, + bottom: 10, + right: 12, + ), + child: Row( + children: [ + UrlIcon([url], iconSize: 32), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + url.host, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isSelected ? colorScheme.primary : null, + ), + ), + if (pageCount > 0) ...[ + const SizedBox(height: 3), + Text( + '$pageCount pages', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: colorScheme.primary, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +Future _showAddConsoleDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + builder: (context) => _AddConsoleDialog(ref: ref), + ); +} + +class _AddConsoleDialog extends HookWidget { + final WidgetRef ref; + + const _AddConsoleDialog({required this.ref}); + + @override + Widget build(BuildContext context) { + final formKey = useMemoized(() => GlobalKey()); + + final textController = useTextEditingController(); + + final isLoading = useState(false); + final errorMessage = useState(null); + + Future submit() async { + errorMessage.value = null; + + if (formKey.currentState?.validate() != true) return; + + final url = parseValidatedUrl( + textController.text, + eagerParsing: true, + onlyHttpProtocol: true, + ); + if (url == null) return; + + isLoading.value = true; + + try { + final service = ref.read(wanderSourceServiceProvider); + final consoleUrl = await service.addConsoleFromUrl(url); + + if (!context.mounted) return; + + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added console ${consoleUrl.host}')), + ); + } on Exception catch (e) { + if (!context.mounted) return; + isLoading.value = false; + errorMessage.value = e.toString().replaceFirst('Exception: ', ''); + } + } + + return AlertDialog( + title: const Text('Add Console'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enter the URL of a Wander console. ' + 'The URL can point to the site root or the /wander/ path.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextFormField( + decoration: const InputDecoration( + label: Text('URL'), + hintText: 'https://example.com/wander/', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + controller: textController, + keyboardType: TextInputType.url, + autofocus: true, + enabled: !isLoading.value, + validator: (value) => validateUrl( + value, + onlyHttpProtocol: true, + eagerParsing: true, + ), + ), + if (errorMessage.value != null) ...[ + const SizedBox(height: 8), + Text( + errorMessage.value!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: isLoading.value ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: isLoading.value ? null : submit, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Add'), + ), + ], + ); + } +} diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index 43e028ea..4645aa24 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -114,6 +114,7 @@ class GeneralSettings with FastEquatable { final bool urlCleanerAutoUpdate; final int? urlCleanerLastCheckEpochMs; final bool urlCleanerLastUpdateWasAuto; + final TabType smallWebTabType; final bool unshortenerEnabled; final String unshortenerToken; @@ -162,6 +163,7 @@ class GeneralSettings with FastEquatable { required this.urlCleanerAutoUpdate, required this.urlCleanerLastCheckEpochMs, required this.urlCleanerLastUpdateWasAuto, + required this.smallWebTabType, required this.unshortenerEnabled, required this.unshortenerToken, }); @@ -211,6 +213,7 @@ class GeneralSettings with FastEquatable { bool? urlCleanerAutoUpdate, this.urlCleanerLastCheckEpochMs, bool? urlCleanerLastUpdateWasAuto, + TabType? smallWebTabType, bool? unshortenerEnabled, String? unshortenerToken, }) : themeMode = themeMode ?? ThemeMode.dark, @@ -267,6 +270,7 @@ class GeneralSettings with FastEquatable { 'https://rules2.clearurls.xyz/rules.minify.hash', urlCleanerAutoUpdate = urlCleanerAutoUpdate ?? false, urlCleanerLastUpdateWasAuto = urlCleanerLastUpdateWasAuto ?? false, + smallWebTabType = smallWebTabType ?? TabType.private, unshortenerEnabled = unshortenerEnabled ?? false, unshortenerToken = unshortenerToken ?? ''; @@ -336,6 +340,7 @@ class GeneralSettings with FastEquatable { urlCleanerAutoUpdate, urlCleanerLastCheckEpochMs, urlCleanerLastUpdateWasAuto, + smallWebTabType, unshortenerEnabled, unshortenerToken, ]; diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 0c11075e..1952add0 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -113,6 +113,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings urlCleanerLastUpdateWasAuto(bool urlCleanerLastUpdateWasAuto); + GeneralSettings smallWebTabType(TabType smallWebTabType); + GeneralSettings unshortenerEnabled(bool unshortenerEnabled); GeneralSettings unshortenerToken(String unshortenerToken); @@ -169,6 +171,7 @@ abstract class _$GeneralSettingsCWProxy { bool urlCleanerAutoUpdate, int? urlCleanerLastCheckEpochMs, bool urlCleanerLastUpdateWasAuto, + TabType smallWebTabType, bool unshortenerEnabled, String unshortenerToken, }); @@ -369,6 +372,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { bool urlCleanerLastUpdateWasAuto, ) => call(urlCleanerLastUpdateWasAuto: urlCleanerLastUpdateWasAuto); + @override + GeneralSettings smallWebTabType(TabType smallWebTabType) => + call(smallWebTabType: smallWebTabType); + @override GeneralSettings unshortenerEnabled(bool unshortenerEnabled) => call(unshortenerEnabled: unshortenerEnabled); @@ -431,6 +438,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? urlCleanerAutoUpdate = const $CopyWithPlaceholder(), Object? urlCleanerLastCheckEpochMs = const $CopyWithPlaceholder(), Object? urlCleanerLastUpdateWasAuto = const $CopyWithPlaceholder(), + Object? smallWebTabType = const $CopyWithPlaceholder(), Object? unshortenerEnabled = const $CopyWithPlaceholder(), Object? unshortenerToken = const $CopyWithPlaceholder(), }) { @@ -693,6 +701,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.urlCleanerLastUpdateWasAuto // ignore: cast_nullable_to_non_nullable : urlCleanerLastUpdateWasAuto as bool, + smallWebTabType: + smallWebTabType == const $CopyWithPlaceholder() || + smallWebTabType == null + ? _value.smallWebTabType + // ignore: cast_nullable_to_non_nullable + : smallWebTabType as TabType, unshortenerEnabled: unshortenerEnabled == const $CopyWithPlaceholder() || unshortenerEnabled == null @@ -808,6 +822,10 @@ GeneralSettings _$GeneralSettingsFromJson( urlCleanerLastCheckEpochMs: (json['urlCleanerLastCheckEpochMs'] as num?) ?.toInt(), urlCleanerLastUpdateWasAuto: json['urlCleanerLastUpdateWasAuto'] as bool?, + smallWebTabType: $enumDecodeNullable( + _$TabTypeEnumMap, + json['smallWebTabType'], + ), unshortenerEnabled: json['unshortenerEnabled'] as bool?, unshortenerToken: json['unshortenerToken'] as String?, ); @@ -870,6 +888,7 @@ Map _$GeneralSettingsToJson( 'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate, 'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs, 'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto, + 'smallWebTabType': _$TabTypeEnumMap[instance.smallWebTabType]!, 'unshortenerEnabled': instance.unshortenerEnabled, 'unshortenerToken': instance.unshortenerToken, }; diff --git a/app/lib/features/user/data/providers.g.dart b/app/lib/features/user/data/providers.g.dart index 6106cb2e..7bfbd44e 100644 --- a/app/lib/features/user/data/providers.g.dart +++ b/app/lib/features/user/data/providers.g.dart @@ -48,7 +48,7 @@ final class UserDatabaseProvider } } -String _$userDatabaseHash() => r'ebc37c5e9604e02f820c733b19f8b7678b335493'; +String _$userDatabaseHash() => r'097505438c098252a322e6d0e49f885d67ebe898'; @ProviderFor(riverpodDatabaseStorage) final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._(); diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index a321b501..1c602865 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -213,6 +213,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { ?.readAs(DriftSqlType.int, db.typeMapping), 'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto'] ?.readAs(DriftSqlType.bool, db.typeMapping), + 'smallWebTabType': settings['smallWebTabType']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), 'unshortenerEnabled': settings['unshortenerEnabled']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index 55a9ebce..f7505c75 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'04764c3b9a71aec077ab2b7894d117f993678b2e'; + r'ae071b136a14d1f907635579f134608023d81766'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/app/lib/features/web_feed/data/providers.g.dart b/app/lib/features/web_feed/data/providers.g.dart index 47f7a2d6..ccc37a98 100644 --- a/app/lib/features/web_feed/data/providers.g.dart +++ b/app/lib/features/web_feed/data/providers.g.dart @@ -48,4 +48,4 @@ final class FeedDatabaseProvider } } -String _$feedDatabaseHash() => r'182dd4203290835fc2188627db08fb0491bd91b2'; +String _$feedDatabaseHash() => r'1ecae87a3de5b2d43136fdb73411727b0b217621'; diff --git a/app/lib/presentation/widgets/sliding_pill_toggle.dart b/app/lib/presentation/widgets/sliding_pill_toggle.dart new file mode 100644 index 00000000..d1aa4e8d --- /dev/null +++ b/app/lib/presentation/widgets/sliding_pill_toggle.dart @@ -0,0 +1,95 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; + +class SlidingPillToggle extends StatelessWidget { + final int selectedIndex; + final List labels; + final ValueChanged onChanged; + + const SlidingPillToggle({ + super.key, + required this.selectedIndex, + required this.labels, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final disableAnimations = MediaQuery.disableAnimationsOf(context); + + return Container( + height: 36, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Stack( + children: [ + AnimatedAlign( + alignment: Alignment( + -1.0 + (2.0 * selectedIndex / (labels.length - 1)), + 0.0, + ), + duration: disableAnimations + ? Duration.zero + : const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: FractionallySizedBox( + widthFactor: 1.0 / labels.length, + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(18), + ), + ), + ), + ), + Row( + children: [ + for (var i = 0; i < labels.length; i++) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(i), + child: Center( + child: Text( + labels[i], + style: theme.textTheme.labelMedium?.copyWith( + color: i == selectedIndex + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + fontWeight: i == selectedIndex + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3a416f9e..08cd829d 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -117,6 +117,7 @@ flutter: - assets/preferences/ - assets/legal/ - assets/quotes/ + - assets/small_web/ fonts: - family: TorIcons fonts: