small web feature initial
This commit is contained in:
@@ -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();
|
||||
|
||||
+25
-38
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+14
-1
@@ -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()),
|
||||
],
|
||||
|
||||
+101
-53
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+5
-74
@@ -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);
|
||||
}
|
||||
}
|
||||
+327
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+254
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user