small web feature initial
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user