small web feature initial

This commit is contained in:
Fabian Freund
2026-03-23 11:13:39 +01:00
parent 1e56bb3e3a
commit b9669635d9
61 changed files with 10455 additions and 233 deletions
+1 -1
View File
@@ -48,4 +48,4 @@ final class BangDatabaseProvider
}
}
String _$bangDatabaseHash() => r'5a51a8c1db43e46f0adc0b08def0dc44b9b2dfd4';
String _$bangDatabaseHash() => r'0369d508def140a32c08c0551cefed57b1ca4b26';
@@ -140,3 +140,23 @@ void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
}
}
}
/// Returns GUIDs of all bookmark entries matching [url] in the tree.
List<String> bookmarkGuidsForUrl(BookmarkItem? root, Uri? url) {
final result = <String>[];
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 <BookmarkItem>[]) {
collect(child);
}
}
}
collect(root);
return result;
}
@@ -42,7 +42,7 @@ final class BrowserAddonServiceProvider
}
String _$browserAddonServiceHash() =>
r'1485fd056ce32e142e342e920affb82a5c77a370';
r'6f07d93576a0816bf1b9e1b36397b89b7cacf43f';
abstract class _$BrowserAddonService extends $Notifier<void> {
void build();
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package: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 <String>[],
),
),
)
.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 <String>[],
),
),
)
.value;
final isBookmarked = existingGuids.isNotEmpty;
return IconButton(
@@ -821,33 +835,6 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget {
}
}
List<String> _bookmarkGuidsForUrl(
BookmarkItem? root,
Uri? tabUrl,
bool bookmarkable,
) {
final result = <String>[];
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 <BookmarkItem>[]) {
collect(child);
}
}
}
collect(root);
return result;
}
Future<void> _adjustFontSize(
BuildContext context,
WidgetRef ref, {
@@ -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<Offset>? 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,
),
);
},
@@ -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()),
],
@@ -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();
}
},
);
}
}
@@ -159,11 +159,13 @@ class _BrowserViewState extends ConsumerState<BrowserView>
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;
@@ -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
@@ -48,4 +48,4 @@ final class TabDatabaseProvider
}
}
String _$tabDatabaseHash() => r'c40af8d5e17f61abcf14ea096ace3d0c2fb3d8d8';
String _$tabDatabaseHash() => r'62466f063f5eae2a32c37dce0496b8e47e7cb953';
@@ -49,4 +49,4 @@ final class TopSiteDatabaseProvider
}
}
String _$topSiteDatabaseHash() => r'bee041ef1b144ce63c38080b2534e4109399fa6f';
String _$topSiteDatabaseHash() => r'a5afe9a807174ae3382931612b25f959efccdded';
@@ -48,4 +48,4 @@ final class QuotesDatabaseProvider
}
}
String _$quotesDatabaseHash() => r'be623fb69eb03f4c264d1a4fd07300fa669ecc86';
String _$quotesDatabaseHash() => r'14491e3de1a188138b329776e1f4d83101b2ba13';
@@ -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();
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SmallWebDatabase>
with $SmallWebItemDaoMixin {
SmallWebItemDao(super.attachedDatabase);
SingleOrNullSelectable<DateTime?> 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<SmallWebItem> getDiscoverableKagiItems(
KagiSmallWebMode mode,
String? category,
) {
return db.definitionsDrift.getDiscoverableKagiItems(
sourceKind: SmallWebSourceKind.kagi,
mode: mode.name,
category: category,
);
}
Future<void> updateTitle(String id, String title) {
return (db.smallWebItems.update()..where((i) => i.id.equals(id))).write(
SmallWebItemsCompanion(
title: Value(title),
updatedAt: Value(DateTime.now()),
),
);
}
}
@@ -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<i1.SmallWebDatabase> {
SmallWebItemDaoManager get managers => SmallWebItemDaoManager(this);
}
class SmallWebItemDaoManager {
final $SmallWebItemDaoMixin _db;
SmallWebItemDaoManager(this._db);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SmallWebDatabase>
with $SmallWebVisitDaoMixin {
SmallWebVisitDao(super.attachedDatabase);
Future<void> insertVisit(SmallWebVisit visit) {
return db.smallWebVisits.insertOne(visit);
}
Selectable<GetRecentVisitsResult> getRecentVisits({
required SmallWebSourceKind sourceKind,
required KagiSmallWebMode? mode,
int limit = 50,
}) {
return db.definitionsDrift.getRecentVisits(
sourceKind: sourceKind,
mode: mode?.name,
limit: limit,
);
}
Selectable<String> getRecentItemIds({
required SmallWebSourceKind sourceKind,
required KagiSmallWebMode? mode,
int limit = 20,
}) {
return db.definitionsDrift.getRecentVisitItemIds(
sourceKind: sourceKind,
mode: mode?.name,
limit: limit,
);
}
Future<void> deleteVisitById(String visitId) {
return (db.delete(
db.smallWebVisits,
)..where((t) => t.id.equals(visitId))).go();
}
Future<void> 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<void> deleteAllVisits() {
return db.delete(db.smallWebVisits).go();
}
}
@@ -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<i1.SmallWebDatabase> {
SmallWebVisitDaoManager get managers => SmallWebVisitDaoManager(this);
}
class SmallWebVisitDaoManager {
final $SmallWebVisitDaoMixin _db;
SmallWebVisitDaoManager(this._db);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SmallWebDatabase>
with $WanderConsoleDaoMixin {
WanderConsoleDao(super.attachedDatabase);
Future<void> 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<WanderConsole?> getConsole(Uri url) {
return (db.wanderConsoles.select()..where((c) => c.url.equalsValue(url)));
}
Selectable<Uri> getExistingConsoleUrls(List<Uri> 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<Uri> getDiscoveredConsoleUrls({int limit = 1000}) {
return db.definitionsDrift.getDiscoveredConsoleUrls(limit: limit);
}
}
@@ -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<i1.SmallWebDatabase> {
WanderConsoleDaoManager get managers => WanderConsoleDaoManager(this);
}
class WanderConsoleDaoManager {
final $WanderConsoleDaoMixin _db;
WanderConsoleDaoManager(this._db);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;');
},
);
}
@@ -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>(i1.DefinitionsDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@override
List<i0.DatabaseSchemaEntity> 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);
},
);
}
}
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, KagiCategoryDefinition> categories;
final Map<String, List<String>> groups;
final Map<String, String> remap;
const KagiCategories({
required this.categories,
required this.groups,
required this.remap,
});
factory KagiCategories.fromJson(Map<String, dynamic> json) {
final rawCategories = json['categories'] as Map<String, dynamic>;
final categories = rawCategories.map(
(slug, data) => MapEntry(
slug,
KagiCategoryDefinition(
slug: slug,
label: (data as Map<String, dynamic>)['label'] as String,
description: data['description'] as String,
emoji: data['emoji'] as String,
),
),
);
final rawGroups = json['groups'] as Map<String, dynamic>;
final groups = rawGroups.map(
(name, slugs) => MapEntry(
name,
(slugs as List<dynamic>).cast<String>(),
),
);
final rawRemap = json['remap'] as Map<String, dynamic>;
final remap = rawRemap.cast<String, String>();
return KagiCategories(
categories: categories,
groups: groups,
remap: remap,
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String> categories;
KagiFeedEntry({
required this.url,
this.title,
this.author,
this.summary,
this.publishedAt,
this.categories = const [],
});
@override
List<Object?> get hashParameters => [
url,
title,
author,
summary,
publishedAt,
categories,
];
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
enum WanderConsoleSource { seed, manual, discovered }
@@ -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 <http://www.gnu.org/licenses/>.
*/
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> kagiCategories(Ref ref) async {
final jsonStr = await rootBundle.loadString(
'assets/small_web/kagi_categories.json',
);
return KagiCategories.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
}
@@ -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<SmallWebDatabase> {
SmallWebDatabaseProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebDatabaseProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebDatabaseHash();
@$internal
@override
$ProviderElement<SmallWebDatabase> $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<SmallWebDatabase>(value),
);
}
}
String _$smallWebDatabaseHash() => r'e199e8c1261e8152110fb99f883fed1e550ffdc6';
@ProviderFor(kagiCategories)
final kagiCategoriesProvider = KagiCategoriesProvider._();
final class KagiCategoriesProvider
extends
$FunctionalProvider<
AsyncValue<KagiCategories>,
KagiCategories,
FutureOr<KagiCategories>
>
with $FutureModifier<KagiCategories>, $FutureProvider<KagiCategories> {
KagiCategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'kagiCategoriesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$kagiCategoriesHash();
@$internal
@override
$FutureProviderElement<KagiCategories> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<KagiCategories> create(Ref ref) {
return kagiCategories(ref);
}
}
String _$kagiCategoriesHash() => r'de97daabc1aba5360209e15f1de0e5488435aa54';
@@ -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 <http://www.gnu.org/licenses/>.
*/
const wanderSeedConsoles = ['https://susam.net/wander/'];
@@ -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 <http://www.gnu.org/licenses/>.
*/
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> 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> smallWebDiscoverService(Ref ref) async {
final kagiService = await ref.watch(kagiSourceServiceProvider.future);
return SmallWebDiscoverService(
ref.watch(smallWebDatabaseProvider),
kagiService,
ref.watch(wanderSourceServiceProvider),
);
}
@Riverpod()
Stream<List<GetRecentVisitsResult>> smallWebRecentVisits(
Ref ref,
SmallWebSourceKind sourceKind,
KagiSmallWebMode? mode,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.smallWebVisitDao
.getRecentVisits(sourceKind: sourceKind, mode: mode)
.watch();
}
@Riverpod()
Stream<Map<KagiSmallWebMode, int>> smallWebAllModeItemCounts(Ref ref) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift.getAllModeItemCounts().watch().map((rows) {
final counts = <KagiSmallWebMode, int>{};
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<List<GetNeighborConsolesWithPageCountsResult>> wanderNeighborConsoles(
Ref ref,
Uri consoleUrl,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift
.getNeighborConsolesWithPageCounts(
sourceKind: SmallWebSourceKind.wander,
sourceConsoleUrl: consoleUrl.toString(),
)
.watch();
}
@Riverpod()
Stream<List<GetAllConsolesWithPageCountsResult>> wanderAllConsoles(
Ref ref,
String query,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift
.getAllConsolesWithPageCounts(
sourceKind: SmallWebSourceKind.wander,
query: query,
)
.watch();
}
@@ -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>,
KagiSourceService,
FutureOr<KagiSourceService>
>
with
$FutureModifier<KagiSourceService>,
$FutureProvider<KagiSourceService> {
KagiSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'kagiSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$kagiSourceServiceHash();
@$internal
@override
$FutureProviderElement<KagiSourceService> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<KagiSourceService> 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<WanderSourceService> {
WanderSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'wanderSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wanderSourceServiceHash();
@$internal
@override
$ProviderElement<WanderSourceService> $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<WanderSourceService>(value),
);
}
}
String _$wanderSourceServiceHash() =>
r'80574ea0c51f56325940edb0fa7e849784ba7421';
@ProviderFor(smallWebDiscoverService)
final smallWebDiscoverServiceProvider = SmallWebDiscoverServiceProvider._();
final class SmallWebDiscoverServiceProvider
extends
$FunctionalProvider<
AsyncValue<SmallWebDiscoverService>,
SmallWebDiscoverService,
FutureOr<SmallWebDiscoverService>
>
with
$FutureModifier<SmallWebDiscoverService>,
$FutureProvider<SmallWebDiscoverService> {
SmallWebDiscoverServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebDiscoverServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebDiscoverServiceHash();
@$internal
@override
$FutureProviderElement<SmallWebDiscoverService> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SmallWebDiscoverService> create(Ref ref) {
return smallWebDiscoverService(ref);
}
}
String _$smallWebDiscoverServiceHash() =>
r'14fba9eb1c2aa51fa1fac2c2c764ea400603e8ca';
@ProviderFor(smallWebRecentVisits)
final smallWebRecentVisitsProvider = SmallWebRecentVisitsFamily._();
final class SmallWebRecentVisitsProvider
extends
$FunctionalProvider<
AsyncValue<List<GetRecentVisitsResult>>,
List<GetRecentVisitsResult>,
Stream<List<GetRecentVisitsResult>>
>
with
$FutureModifier<List<GetRecentVisitsResult>>,
$StreamProvider<List<GetRecentVisitsResult>> {
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<List<GetRecentVisitsResult>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<GetRecentVisitsResult>> 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<List<GetRecentVisitsResult>>,
(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<KagiSmallWebMode, int>>,
Map<KagiSmallWebMode, int>,
Stream<Map<KagiSmallWebMode, int>>
>
with
$FutureModifier<Map<KagiSmallWebMode, int>>,
$StreamProvider<Map<KagiSmallWebMode, int>> {
SmallWebAllModeItemCountsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebAllModeItemCountsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebAllModeItemCountsHash();
@$internal
@override
$StreamProviderElement<Map<KagiSmallWebMode, int>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<KagiSmallWebMode, int>> 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<GetNeighborConsolesWithPageCountsResult>>,
List<GetNeighborConsolesWithPageCountsResult>,
Stream<List<GetNeighborConsolesWithPageCountsResult>>
>
with
$FutureModifier<List<GetNeighborConsolesWithPageCountsResult>>,
$StreamProvider<List<GetNeighborConsolesWithPageCountsResult>> {
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<List<GetNeighborConsolesWithPageCountsResult>>
$createElement($ProviderPointer pointer) => $StreamProviderElement(pointer);
@override
Stream<List<GetNeighborConsolesWithPageCountsResult>> 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<List<GetNeighborConsolesWithPageCountsResult>>,
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<GetAllConsolesWithPageCountsResult>>,
List<GetAllConsolesWithPageCountsResult>,
Stream<List<GetAllConsolesWithPageCountsResult>>
>
with
$FutureModifier<List<GetAllConsolesWithPageCountsResult>>,
$StreamProvider<List<GetAllConsolesWithPageCountsResult>> {
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<List<GetAllConsolesWithPageCountsResult>>
$createElement($ProviderPointer pointer) => $StreamProviderElement(pointer);
@override
Stream<List<GetAllConsolesWithPageCountsResult>> 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<List<GetAllConsolesWithPageCountsResult>>,
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';
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, String> categoryRemap,
});
class KagiSourceService {
final SmallWebDatabase _db;
final Map<String, String> _categoryRemap;
KagiSourceService(this._db, this._categoryRemap);
Future<bool> 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<void> fetchAndIngest(KagiSmallWebMode mode) async {
final request = (
token: ServicesBinding.rootIsolateToken!,
url: mode.feedUrl.toString(),
mode: mode.name,
categoryRemap: Map<String, String>.from(_categoryRemap),
);
final List<KagiFeedEntry> 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<List<KagiFeedEntry>> _runKagiFeedFetch(_KagiFeedFetchRequest request) {
return Isolate.run(_createKagiFeedFetchTask(request));
}
Future<List<KagiFeedEntry>> Function() _createKagiFeedFetchTask(
_KagiFeedFetchRequest request,
) {
return () => _fetchAndParseFeed(
request.token,
Uri.parse(request.url),
request.mode,
request.categoryRemap,
);
}
Future<List<KagiFeedEntry>> _fetchAndParseFeed(
RootIsolateToken token,
Uri url,
String mode,
Map<String, String> 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();
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Object?> get hashParameters => [item, consoleUrl];
}
class SmallWebDiscoverService {
final SmallWebDatabase _db;
final KagiSourceService _kagiService;
final WanderSourceService _wanderService;
SmallWebDiscoverService(this._db, this._kagiService, this._wanderService);
Future<void> 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<SmallWebItem?> 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<WanderDiscoverResult?> 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<void> updateItemTitle(String itemId, String title) {
return _db.smallWebItemDao.updateTitle(itemId, title);
}
Future<List<SmallWebItem>> _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<WanderDiscoverResult?> _tryAlternativeConsoles(
Uri excludeConsole,
Set<String> 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<WanderDiscoverResult> _pickAndRecord(
List<SmallWebItem> 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);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
import 'package:weblibre/extensions/uri.dart';
final _lineCommentPattern = RegExp(r'(?<!:)//.*$', multiLine: true);
final _blockCommentPattern = RegExp(r'/\*[\s\S]*?\*/');
final _consolesPattern = RegExp(r'consoles\s*:\s*\[([\s\S]*?)\]', dotAll: true);
final _pagesPattern = RegExp(r'pages\s*:\s*\[([\s\S]*?)\]', dotAll: true);
// ignore: unnecessary_raw_strings
final _stringPattern = RegExp(r'''(?:["'`])([^"'`]+)(?:["'`])''');
List<String> _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<Uri> consoles;
final List<Uri> 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(),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<bool> 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<void> 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<WanderJsResult?> 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<bool> 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<WanderJsResult> 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<Uri> 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<List<Uri>> getDiscoveredConsoleUrls() {
return _db.wanderConsoleDao.getDiscoveredConsoleUrls().get();
}
Future<List<SmallWebItem>> getPagesForConsole(Uri consoleUrl) {
return _db.definitionsDrift
.getWanderPagesForConsole(
sourceKind: SmallWebSourceKind.wander,
consoleUrl: consoleUrl.toString(),
)
.get();
}
Future<void> _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<WanderJsResult?> _runWanderJsFetch(_WanderJsFetchRequest request) {
return Isolate.run(_createWanderJsFetchTask(request));
}
Future<WanderJsResult?> Function() _createWanderJsFetchTask(
_WanderJsFetchRequest request,
) {
return () => _fetchAndParseWanderJs(request.token, Uri.parse(request.url));
}
Future<WanderJsResult?> _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();
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<void> exit() async {
if (_smallWebTabId == null) return;
final tabId = _smallWebTabId!;
_smallWebTabId = null;
state = null;
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
}
}
@@ -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<SmallWebModeController, String?> {
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<String?>(value),
);
}
}
String _$smallWebModeControllerHash() =>
r'26fb84c9ce60562738f7d9523f74aebfb19ad84b';
abstract class _$SmallWebModeController extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> json) =>
_$SmallWebSessionStateFromJson(json);
Map<String, dynamic> 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<Object?> get hashParameters => [
sourceKind,
mode,
currentCategory,
currentItemId,
currentItemUrl,
currentConsoleUrl,
infoMessage,
];
}
@Riverpod(keepAlive: true)
class SmallWebSessionController extends _$SmallWebSessionController {
@override
AsyncValue<SmallWebSessionState> 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<String, dynamic>,
);
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<void> discover({bool forceNewConsole = false}) async {
if (state.isLoading) return;
if (!state.hasValue) return;
final session = state.requireValue.copyWith(infoMessage: null);
state = const AsyncLoading<SmallWebSessionState>();
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<SmallWebSessionState>(
'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<void> 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<void> 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,
),
);
}
}
}
@@ -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<String, dynamic> 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<String, dynamic> _$SmallWebSessionStateToJson(
SmallWebSessionState instance,
) => <String, dynamic>{
'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<SmallWebSessionState>
> {
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<SmallWebSessionState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<SmallWebSessionState>>(
value,
),
);
}
}
String _$smallWebSessionControllerHash() =>
r'90680807b38de577e3322941b47792952d66428a';
abstract class _$SmallWebSessionController
extends $Notifier<AsyncValue<SmallWebSessionState>> {
AsyncValue<SmallWebSessionState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<SmallWebSessionState>,
AsyncValue<SmallWebSessionState>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<SmallWebSessionState>,
AsyncValue<SmallWebSessionState>
>,
AsyncValue<SmallWebSessionState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SmallWebAttributionAction> 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<Uri> 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),
),
],
),
],
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 <String>[],
),
),
)
.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,
),
],
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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(),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<bool>(
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,
),
),
],
),
),
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<SmallWebSourceKind> onChanged;
const _SourceKindChip({required this.sourceKind, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<SmallWebSourceKind>(
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<String, KagiCategoryDefinition> categories;
final List<String> slugs;
final String? currentCategory;
final ValueChanged<String> 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(),
),
],
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<KagiSmallWebMode> 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),
),
);
},
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<bool?>(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<bool> 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<void> _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<FormState>());
final textController = useTextEditingController();
final isLoading = useState(false);
final errorMessage = useState<String?>(null);
Future<void> 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'),
),
],
);
}
}
@@ -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,
];
@@ -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<String, dynamic> _$GeneralSettingsToJson(
'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate,
'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs,
'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto,
'smallWebTabType': _$TabTypeEnumMap[instance.smallWebTabType]!,
'unshortenerEnabled': instance.unshortenerEnabled,
'unshortenerToken': instance.unshortenerToken,
};
+1 -1
View File
@@ -48,7 +48,7 @@ final class UserDatabaseProvider
}
}
String _$userDatabaseHash() => r'ebc37c5e9604e02f820c733b19f8b7678b335493';
String _$userDatabaseHash() => r'097505438c098252a322e6d0e49f885d67ebe898';
@ProviderFor(riverpodDatabaseStorage)
final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._();
@@ -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,
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'04764c3b9a71aec077ab2b7894d117f993678b2e';
r'ae071b136a14d1f907635579f134608023d81766';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {
@@ -48,4 +48,4 @@ final class FeedDatabaseProvider
}
}
String _$feedDatabaseHash() => r'182dd4203290835fc2188627db08fb0491bd91b2';
String _$feedDatabaseHash() => r'1ecae87a3de5b2d43136fdb73411727b0b217621';