small web feature initial
This commit is contained in:
@@ -96,6 +96,14 @@ By combining these tools, WebLibre empowers you to browse the web freely and pri
|
|||||||
- **DNS over HTTPS (DoH):** Encrypted DNS with predefined or custom resolver options
|
- **DNS over HTTPS (DoH):** Encrypted DNS with predefined or custom resolver options
|
||||||
- **Container Data Isolation:** Clear container data for isolated containers
|
- **Container Data Isolation:** Clear container data for isolated containers
|
||||||
|
|
||||||
|
### [Small Web Discovery](https://docs.weblibre.eu/small-web)
|
||||||
|
|
||||||
|
- **Discover the Small Web:** A dedicated browsing mode for discovering personal blogs, indie creators, and handcrafted websites — the hidden corners of the internet made by real people
|
||||||
|
- **Kagi Small Web:** Browse curated content from thousands of community-vetted personal blogs, indie YouTube channels, and web comics across five modes (Web, Appreciated, Videos, Code, Comics)
|
||||||
|
- **Wander Network:** Explore a decentralised network of personal websites where each site recommends pages and links to other sites, forming a growing web of discoveries
|
||||||
|
- **Category Filters:** Narrow discoveries by topic — from Programming and Science to Art, Food, Travel, and more
|
||||||
|
- **Discovery History:** Track and revisit pages you have found, with smart avoidance of recently seen content
|
||||||
|
|
||||||
### Productivity & Content Management
|
### Productivity & Content Management
|
||||||
|
|
||||||
- **Bookmark Management** - Import/export bookmarks (JSON/HTML format)
|
- **Bookmark Management** - Import/export bookmarks (JSON/HTML format)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WebLibre Privacy Policy
|
# WebLibre Privacy Policy
|
||||||
|
|
||||||
**Version 1.2**
|
**Version 1.3**
|
||||||
**Last Updated:** 2026-03-15
|
**Last Updated:** 2026-03-22
|
||||||
|
|
||||||
**Controller:**
|
**Controller:**
|
||||||
OnDevice UG (haftungsbeschränkt)
|
OnDevice UG (haftungsbeschränkt)
|
||||||
@@ -135,7 +135,19 @@ If you use speech-to-text or voice-input features, WebLibre invokes Android's sp
|
|||||||
|
|
||||||
**Legal basis:** Consent (Art. 6(1)(a) GDPR) — you choose to invoke the feature.
|
**Legal basis:** Consent (Art. 6(1)(a) GDPR) — you choose to invoke the feature.
|
||||||
|
|
||||||
### 3.13 QR Scanning and Camera-Based Pairing (Optional)
|
### 3.13 Small Web Discovery (Optional)
|
||||||
|
|
||||||
|
When you use Small Web mode, WebLibre fetches content from third-party sources to help you discover independent websites and personal blogs.
|
||||||
|
|
||||||
|
**Kagi Small Web:** WebLibre fetches publicly available Atom feeds from Kagi (`kagi.com`) to retrieve curated lists of personal blog posts, indie videos, code projects, and web comics. Feed content is cached locally on your device.
|
||||||
|
|
||||||
|
**Wander:** WebLibre fetches small JavaScript files (`wander.js`) from independently operated personal websites that participate in the Wander network. These files contain lists of recommended pages and links to other participating sites. Your device connects directly to each console host.
|
||||||
|
|
||||||
|
**Data sent:** Standard HTTP(S) requests to Kagi's feed endpoints and to Wander console hosts. Your IP address is visible to these servers. Discovered content (URLs, titles, authors, summaries) is stored locally on your device. No account or personal data is sent beyond what is included in standard HTTP requests.
|
||||||
|
|
||||||
|
**Legal basis:** Consent (Art. 6(1)(a) GDPR) — you choose to enter Small Web mode and initiate discovery.
|
||||||
|
|
||||||
|
### 3.14 QR Scanning and Camera-Based Pairing (Optional)
|
||||||
|
|
||||||
If you use QR scanning features (such as pairing a Firefox Sync account from a desktop browser), the app uses the device camera locally to scan the code.
|
If you use QR scanning features (such as pairing a Firefox Sync account from a desktop browser), the app uses the device camera locally to scan the code.
|
||||||
|
|
||||||
@@ -167,7 +179,7 @@ Where applicable under the GDPR, we process personal data on one or more of the
|
|||||||
|
|
||||||
**Summary of legal bases:**
|
**Summary of legal bases:**
|
||||||
|
|
||||||
- **Consent** (Art. 6(1)(a) GDPR) — for optional features you choose to enable, such as search suggestions (3.2), Firefox Sync (3.4), DNS-over-HTTPS (3.5), URL unshortening (3.8), Tor routing (3.9), URL cleaner catalog updates (3.11), voice input (3.12), and QR scanning (3.13);
|
- **Consent** (Art. 6(1)(a) GDPR) — for optional features you choose to enable, such as search suggestions (3.2), Firefox Sync (3.4), DNS-over-HTTPS (3.5), URL unshortening (3.8), Tor routing (3.9), URL cleaner catalog updates (3.11), voice input (3.12), Small Web discovery (3.13), and QR scanning (3.14);
|
||||||
- **Contract performance** (Art. 6(1)(b) GDPR) — for features inherent to the service you requested, such as web browsing (3.1), search queries (3.3), extension installation (3.7), and feed fetching (3.10);
|
- **Contract performance** (Art. 6(1)(b) GDPR) — for features inherent to the service you requested, such as web browsing (3.1), search queries (3.3), extension installation (3.7), and feed fetching (3.10);
|
||||||
- **Legitimate interest** (Art. 6(1)(f) GDPR) — for security and protection features such as tracking protection and safe browsing (3.6);
|
- **Legitimate interest** (Art. 6(1)(f) GDPR) — for security and protection features such as tracking protection and safe browsing (3.6);
|
||||||
- **Legal obligation** (Art. 6(1)(c) GDPR) — where required by applicable law;
|
- **Legal obligation** (Art. 6(1)(c) GDPR) — where required by applicable law;
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"categories": {
|
||||||
|
"ai": {
|
||||||
|
"label": "AI",
|
||||||
|
"description": "LLMs · agents · prompts · AI ethics · AI safety",
|
||||||
|
"emoji": "✨"
|
||||||
|
},
|
||||||
|
"programming": {
|
||||||
|
"label": "Programming",
|
||||||
|
"description": "Code · frameworks · devtools · APIs · tutorials · open source",
|
||||||
|
"emoji": "🧩"
|
||||||
|
},
|
||||||
|
"tech": {
|
||||||
|
"label": "Technology",
|
||||||
|
"description": "Tech news · gadgets · apps · platforms · social media",
|
||||||
|
"emoji": "📱"
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"label": "Sysadmin & Security",
|
||||||
|
"description": "Servers · cloud · containers · networking · infosec · privacy",
|
||||||
|
"emoji": "🛡️"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"label": "Web & Internet",
|
||||||
|
"description": "The open web · RSS · blogging · IndieWeb · web standards",
|
||||||
|
"emoji": "🕸️"
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"label": "Hardware",
|
||||||
|
"description": "Electronics · home lab · PCB design · keyboards · audio gear",
|
||||||
|
"emoji": "🎛️"
|
||||||
|
},
|
||||||
|
"diy": {
|
||||||
|
"label": "DIY & Making",
|
||||||
|
"description": "Woodworking · metalworking · 3D printing · home · maker",
|
||||||
|
"emoji": "🛠️"
|
||||||
|
},
|
||||||
|
"retro": {
|
||||||
|
"label": "Retro",
|
||||||
|
"description": "Vintage computers · DOS · BBS · demoscene · old software",
|
||||||
|
"emoji": "💾"
|
||||||
|
},
|
||||||
|
"science": {
|
||||||
|
"label": "Science",
|
||||||
|
"description": "Physics · biology · math · space · climate · research",
|
||||||
|
"emoji": "⚛️"
|
||||||
|
},
|
||||||
|
"humanities": {
|
||||||
|
"label": "Humanities",
|
||||||
|
"description": "History · philosophy · linguistics · archaeology · classics",
|
||||||
|
"emoji": "🏺"
|
||||||
|
},
|
||||||
|
"essays": {
|
||||||
|
"label": "Essays",
|
||||||
|
"description": "Long-form reflective writing that defies topic categories",
|
||||||
|
"emoji": "🪶"
|
||||||
|
},
|
||||||
|
"art": {
|
||||||
|
"label": "Art & Design",
|
||||||
|
"description": "Visual art · illustration · typography · creative writing",
|
||||||
|
"emoji": "🌊"
|
||||||
|
},
|
||||||
|
"photography": {
|
||||||
|
"label": "Photography",
|
||||||
|
"description": "Photographic craft · technique · gear · film · photo essays",
|
||||||
|
"emoji": "🌄"
|
||||||
|
},
|
||||||
|
"culture": {
|
||||||
|
"label": "Pop Culture",
|
||||||
|
"description": "Film · TV · music · books · comics",
|
||||||
|
"emoji": "🎭"
|
||||||
|
},
|
||||||
|
"gaming": {
|
||||||
|
"label": "Gaming",
|
||||||
|
"description": "Video games · game dev · modding · interactive fiction",
|
||||||
|
"emoji": "🕹️"
|
||||||
|
},
|
||||||
|
"politics": {
|
||||||
|
"label": "Politics",
|
||||||
|
"description": "Government · legislation · international relations",
|
||||||
|
"emoji": "🎤"
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"label": "Economy",
|
||||||
|
"description": "Markets · finance · labor economics · trade · analysis",
|
||||||
|
"emoji": "🎲"
|
||||||
|
},
|
||||||
|
"society": {
|
||||||
|
"label": "Society",
|
||||||
|
"description": "Discrimination · civil rights · social structure",
|
||||||
|
"emoji": "👥"
|
||||||
|
},
|
||||||
|
"life": {
|
||||||
|
"label": "Life & Personal",
|
||||||
|
"description": "Diary · weeknotes · parenting · pets · link roundups",
|
||||||
|
"emoji": "☀️"
|
||||||
|
},
|
||||||
|
"food": {
|
||||||
|
"label": "Food & Drink",
|
||||||
|
"description": "Recipes · cooking · restaurants · coffee · wine · baking",
|
||||||
|
"emoji": "🧑🍳"
|
||||||
|
},
|
||||||
|
"travel": {
|
||||||
|
"label": "Travel & Outdoors",
|
||||||
|
"description": "Trip reports · hiking · nature · birdwatching · gardening",
|
||||||
|
"emoji": "✈️"
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"label": "Health & Fitness",
|
||||||
|
"description": "Fitness · nutrition · mental health · longevity",
|
||||||
|
"emoji": "🏃"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"Tech & Science": [
|
||||||
|
"ai",
|
||||||
|
"science",
|
||||||
|
"programming",
|
||||||
|
"diy",
|
||||||
|
"tech",
|
||||||
|
"hardware",
|
||||||
|
"infra",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"Culture & Creative": [
|
||||||
|
"health",
|
||||||
|
"art",
|
||||||
|
"essays",
|
||||||
|
"humanities",
|
||||||
|
"retro",
|
||||||
|
"photography",
|
||||||
|
"culture",
|
||||||
|
"gaming"
|
||||||
|
],
|
||||||
|
"Life & World": [
|
||||||
|
"society",
|
||||||
|
"life",
|
||||||
|
"food",
|
||||||
|
"travel",
|
||||||
|
"politics",
|
||||||
|
"economy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"remap": {
|
||||||
|
"sysadmin": "infra",
|
||||||
|
"security": "infra"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ class StringListConverter extends TypeConverter<List<String>, String> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<String> fromSql(String fromDb) {
|
List<String> fromSql(String fromDb) {
|
||||||
return jsonDecode(fromDb) as List<String>;
|
return (jsonDecode(fromDb) as List).cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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() =>
|
String _$browserAddonServiceHash() =>
|
||||||
r'1485fd056ce32e142e342e920affb82a5c77a370';
|
r'6f07d93576a0816bf1b9e1b36397b89b7cacf43f';
|
||||||
|
|
||||||
abstract class _$BrowserAddonService extends $Notifier<void> {
|
abstract class _$BrowserAddonService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+25
-38
@@ -17,6 +17,7 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* 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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.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/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_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/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/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/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/domain/entities/toolbar_button_spec.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.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 tabUrl = scope.tabState?.url;
|
||||||
final bookmarkable = tabUrl != null && !scope.isPreview;
|
final bookmarkable = tabUrl != null && !scope.isPreview;
|
||||||
|
|
||||||
final existingGuids = ref.watch(
|
final existingGuids = ref
|
||||||
bookmarksRepositoryProvider.select(
|
.watch(
|
||||||
(async) => _bookmarkGuidsForUrl(async.value, tabUrl, bookmarkable),
|
bookmarksRepositoryProvider.select(
|
||||||
),
|
(async) => EquatableValue(
|
||||||
);
|
bookmarkable
|
||||||
|
? bookmarkGuidsForUrl(async.value, tabUrl)
|
||||||
|
: const <String>[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value;
|
||||||
|
|
||||||
final isBookmarked = existingGuids.isNotEmpty;
|
final isBookmarked = existingGuids.isNotEmpty;
|
||||||
|
|
||||||
@@ -769,11 +776,18 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabUrl = scope.tabState?.url;
|
final tabUrl = scope.tabState?.url;
|
||||||
final bookmarkable = tabUrl != null && !scope.isPreview;
|
final bookmarkable = tabUrl != null && !scope.isPreview;
|
||||||
final existingGuids = ref.watch(
|
final existingGuids = ref
|
||||||
bookmarksRepositoryProvider.select(
|
.watch(
|
||||||
(async) => _bookmarkGuidsForUrl(async.value, tabUrl, bookmarkable),
|
bookmarksRepositoryProvider.select(
|
||||||
),
|
(async) => EquatableValue(
|
||||||
);
|
bookmarkable
|
||||||
|
? bookmarkGuidsForUrl(async.value, tabUrl)
|
||||||
|
: const <String>[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.value;
|
||||||
|
|
||||||
final isBookmarked = existingGuids.isNotEmpty;
|
final isBookmarked = existingGuids.isNotEmpty;
|
||||||
|
|
||||||
return IconButton(
|
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(
|
Future<void> _adjustFontSize(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref, {
|
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/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/find_in_page/presentation/widgets/find_in_page.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.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/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/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 bool showQuickTabSwitcherBar;
|
||||||
final Stream<Offset>? pointerMoveEvents;
|
final Stream<Offset>? pointerMoveEvents;
|
||||||
final TabBarPosition tabBarPosition;
|
final TabBarPosition tabBarPosition;
|
||||||
|
final bool isSmallWebMode;
|
||||||
|
final bool enableGestures;
|
||||||
|
|
||||||
const _TabBar({
|
const _TabBar({
|
||||||
required this.showMainToolbar,
|
required this.showMainToolbar,
|
||||||
@@ -122,6 +126,8 @@ class _TabBar extends HookConsumerWidget {
|
|||||||
required this.showQuickTabSwitcherBar,
|
required this.showQuickTabSwitcherBar,
|
||||||
required this.tabBarPosition,
|
required this.tabBarPosition,
|
||||||
required this.pointerMoveEvents,
|
required this.pointerMoveEvents,
|
||||||
|
required this.isSmallWebMode,
|
||||||
|
this.enableGestures = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -205,12 +211,15 @@ class _TabBar extends HookConsumerWidget {
|
|||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||||
|
isSmallWebMode: isSmallWebMode,
|
||||||
|
enableGestures: enableGestures,
|
||||||
),
|
),
|
||||||
TabBarPosition.bottom => BrowserBottomAppBar(
|
TabBarPosition.bottom => BrowserBottomAppBar(
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||||
|
isSmallWebMode: isSmallWebMode,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -233,23 +242,33 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
final overlayController = useOverlayPortalController();
|
final overlayController = useOverlayPortalController();
|
||||||
|
|
||||||
final tabBarPosition = ref.watch(
|
final isSmallWebActive = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
smallWebModeControllerProvider.select((value) => value != null),
|
||||||
(value) => value.tabBarPosition,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final showContextualToolbar = ref.watch(
|
final tabBarPosition = isSmallWebActive
|
||||||
generalSettingsWithDefaultsProvider.select(
|
? TabBarPosition.top
|
||||||
(value) => value.tabBarShowContextualBar,
|
: ref.watch(
|
||||||
),
|
generalSettingsWithDefaultsProvider.select(
|
||||||
);
|
(value) => value.tabBarPosition,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final showQuickTabSwitcherBar = ref.watch(
|
final showContextualToolbar =
|
||||||
generalSettingsWithDefaultsProvider.select(
|
!isSmallWebActive &&
|
||||||
(value) => value.tabBarShowQuickTabSwitcherBar,
|
ref.watch(
|
||||||
),
|
generalSettingsWithDefaultsProvider.select(
|
||||||
);
|
(value) => value.tabBarShowContextualBar,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final showQuickTabSwitcherBar =
|
||||||
|
!isSmallWebActive &&
|
||||||
|
ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(value) => value.tabBarShowQuickTabSwitcherBar,
|
||||||
|
),
|
||||||
|
);
|
||||||
final quickTabSwitcherMode = ref.watch(
|
final quickTabSwitcherMode = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
generalSettingsWithDefaultsProvider.select(
|
||||||
(value) => value.effectiveUiQuickTabSwitcherMode(),
|
(value) => value.effectiveUiQuickTabSwitcherMode(),
|
||||||
@@ -261,11 +280,13 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
final displayQuickTabSwitcherBar =
|
final displayQuickTabSwitcherBar =
|
||||||
showQuickTabSwitcherBar && (quickTabSwitcherHasResults.value ?? false);
|
showQuickTabSwitcherBar && (quickTabSwitcherHasResults.value ?? false);
|
||||||
|
|
||||||
final autoHideTabBar = ref.watch(
|
final autoHideTabBar =
|
||||||
generalSettingsWithDefaultsProvider.select(
|
!isSmallWebActive &&
|
||||||
(value) => value.autoHideTabBar,
|
ref.watch(
|
||||||
),
|
generalSettingsWithDefaultsProvider.select(
|
||||||
);
|
(value) => value.autoHideTabBar,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
ref.listen(overlayControllerProvider, (previous, next) {
|
ref.listen(overlayControllerProvider, (previous, next) {
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
@@ -338,13 +359,21 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
// Calculate bottom toolbar size for FAB and sheet positioning
|
// Calculate bottom toolbar size for FAB and sheet positioning
|
||||||
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
|
final Size bottomAppBarContentSize;
|
||||||
final bottomAppBarContentSize = BrowserBottomAppBar(
|
if (isSmallWebActive) {
|
||||||
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
|
bottomAppBarContentSize = const Size.fromHeight(
|
||||||
showContextualToolbar: showContextualToolbar,
|
SmallWebBrowserOverlay.barHeight,
|
||||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
);
|
||||||
displayedSheet: displayedSheet,
|
} else {
|
||||||
).preferredSize;
|
// 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
|
// Total height includes safe area padding
|
||||||
final bottomAppBarTotalHeight =
|
final bottomAppBarTotalHeight =
|
||||||
bottomAppBarContentSize.height + bottomSafeArea;
|
bottomAppBarContentSize.height + bottomSafeArea;
|
||||||
@@ -355,6 +384,8 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
showMainToolbar: tabBarPosition == TabBarPosition.top,
|
showMainToolbar: tabBarPosition == TabBarPosition.top,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
||||||
|
isSmallWebMode: isSmallWebActive,
|
||||||
|
enableGestures: !isSmallWebActive,
|
||||||
).preferredSize;
|
).preferredSize;
|
||||||
final topAppBarTotalHeight = topAppBarContentSize.height + topSafeArea;
|
final topAppBarTotalHeight = topAppBarContentSize.height + topSafeArea;
|
||||||
|
|
||||||
@@ -623,36 +654,49 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Layer 2: Bottom Toolbar (overlay, slides in/out)
|
// Layer 2: Bottom Toolbar (overlay, slides in/out)
|
||||||
|
// In small web mode, show the discovery overlay instead
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Consumer(
|
child: isSmallWebActive
|
||||||
builder: (context, ref, _) {
|
? _AnimatedToolbar(
|
||||||
final toolbarState = ref.watch(
|
position: TabBarPosition.bottom,
|
||||||
toolbarVisibilityControllerProvider(selectedTabId),
|
visible: !tabInFullScreen,
|
||||||
);
|
child: Material(
|
||||||
final visible =
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
sheetDisplayed ||
|
elevation: 3,
|
||||||
(!tabInFullScreen &&
|
child: const SmallWebBrowserOverlay(),
|
||||||
toolbarState == ToolbarVisibility.visible);
|
),
|
||||||
return _AnimatedToolbar(
|
)
|
||||||
position: TabBarPosition.bottom,
|
: Consumer(
|
||||||
visible: visible,
|
builder: (context, ref, _) {
|
||||||
child: _TabBar(
|
final toolbarState = ref.watch(
|
||||||
tabBarPosition: TabBarPosition.bottom,
|
toolbarVisibilityControllerProvider(selectedTabId),
|
||||||
showMainToolbar:
|
);
|
||||||
tabBarPosition == TabBarPosition.bottom,
|
final visible =
|
||||||
showContextualToolbar: showContextualToolbar,
|
sheetDisplayed ||
|
||||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
(!tabInFullScreen &&
|
||||||
pointerMoveEvents:
|
toolbarState == ToolbarVisibility.visible);
|
||||||
tabBarPosition == TabBarPosition.bottom
|
return _AnimatedToolbar(
|
||||||
? pointerMoveEventsController.stream
|
position: TabBarPosition.bottom,
|
||||||
: null,
|
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
|
// Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top
|
||||||
@@ -678,7 +722,11 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
showMainToolbar: true,
|
showMainToolbar: true,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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/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/entities/sync_repository_state.dart';
|
||||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
@@ -1918,7 +1919,19 @@ class _QuickLinksGrid extends ConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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 SizedBox(width: 8),
|
||||||
const Expanded(child: SizedBox.shrink()),
|
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/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_icon.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.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/data/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.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 showMainToolbar;
|
||||||
final bool showContextualToolbar;
|
final bool showContextualToolbar;
|
||||||
final bool showQuickTabSwitcherBar;
|
final bool showQuickTabSwitcherBar;
|
||||||
|
final bool isSmallWebMode;
|
||||||
|
final bool enableGestures;
|
||||||
|
|
||||||
late final BrowserTabBar _tabBar;
|
late final BrowserTabBar _tabBar;
|
||||||
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
|
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
|
||||||
@@ -61,12 +66,16 @@ class BrowserTopAppBar extends StatelessWidget {
|
|||||||
required this.showMainToolbar,
|
required this.showMainToolbar,
|
||||||
required this.showContextualToolbar,
|
required this.showContextualToolbar,
|
||||||
required this.showQuickTabSwitcherBar,
|
required this.showQuickTabSwitcherBar,
|
||||||
|
required this.isSmallWebMode,
|
||||||
|
this.enableGestures = true,
|
||||||
}) {
|
}) {
|
||||||
_tabBar = BrowserTabBar(
|
_tabBar = BrowserTabBar(
|
||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
displayedSheet: null,
|
displayedSheet: null,
|
||||||
showContextualToolbar: false,
|
showContextualToolbar: false,
|
||||||
showQuickTabSwitcherBar: false,
|
showQuickTabSwitcherBar: false,
|
||||||
|
isSmallWebMode: isSmallWebMode,
|
||||||
|
enableGestures: enableGestures,
|
||||||
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
||||||
showContextualToolbar,
|
showContextualToolbar,
|
||||||
);
|
);
|
||||||
@@ -86,7 +95,9 @@ class BrowserBottomAppBar extends StatelessWidget {
|
|||||||
final bool showMainToolbar;
|
final bool showMainToolbar;
|
||||||
final bool showContextualToolbar;
|
final bool showContextualToolbar;
|
||||||
final bool showQuickTabSwitcherBar;
|
final bool showQuickTabSwitcherBar;
|
||||||
|
final bool isSmallWebMode;
|
||||||
final Sheet? displayedSheet;
|
final Sheet? displayedSheet;
|
||||||
|
final bool enableGestures;
|
||||||
|
|
||||||
late final BrowserTabBar _tabBar;
|
late final BrowserTabBar _tabBar;
|
||||||
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
|
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
|
||||||
@@ -97,12 +108,16 @@ class BrowserBottomAppBar extends StatelessWidget {
|
|||||||
required this.displayedSheet,
|
required this.displayedSheet,
|
||||||
required this.showContextualToolbar,
|
required this.showContextualToolbar,
|
||||||
required this.showQuickTabSwitcherBar,
|
required this.showQuickTabSwitcherBar,
|
||||||
|
required this.isSmallWebMode,
|
||||||
|
this.enableGestures = true,
|
||||||
}) {
|
}) {
|
||||||
_tabBar = BrowserTabBar(
|
_tabBar = BrowserTabBar(
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||||
|
isSmallWebMode: isSmallWebMode,
|
||||||
|
enableGestures: enableGestures,
|
||||||
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
||||||
showContextualToolbar,
|
showContextualToolbar,
|
||||||
);
|
);
|
||||||
@@ -133,6 +148,8 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
final bool showQuickTabSwitcherBar;
|
final bool showQuickTabSwitcherBar;
|
||||||
final Sheet? displayedSheet;
|
final Sheet? displayedSheet;
|
||||||
final bool hideMainToolbarButtonsDuplicatedInContextualToolbar;
|
final bool hideMainToolbarButtonsDuplicatedInContextualToolbar;
|
||||||
|
final bool isSmallWebMode;
|
||||||
|
final bool enableGestures;
|
||||||
|
|
||||||
const BrowserTabBar({
|
const BrowserTabBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -140,6 +157,8 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
required this.displayedSheet,
|
required this.displayedSheet,
|
||||||
required this.showContextualToolbar,
|
required this.showContextualToolbar,
|
||||||
required this.showQuickTabSwitcherBar,
|
required this.showQuickTabSwitcherBar,
|
||||||
|
required this.isSmallWebMode,
|
||||||
|
required this.enableGestures,
|
||||||
this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false,
|
this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,8 +214,9 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
c.buttonId == ToolbarButtonId.navigationMenu.name && c.isVisible,
|
c.buttonId == ToolbarButtonId.navigationMenu.name && c.isVisible,
|
||||||
);
|
);
|
||||||
|
|
||||||
final showMainToolbarTabsCount = !tabsCountInContextual;
|
final showMainToolbarTabsCount = !isSmallWebMode && !tabsCountInContextual;
|
||||||
final showMainToolbarNavigationButton = !menuInContextual;
|
final showMainToolbarNavigationButton =
|
||||||
|
!isSmallWebMode && !menuInContextual;
|
||||||
|
|
||||||
final containerColor = ref.watch(
|
final containerColor = ref.watch(
|
||||||
watchTabContainerDataProvider(
|
watchTabContainerDataProvider(
|
||||||
@@ -232,6 +252,19 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
: const AppBarTitle()
|
: const AppBarTitle()
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
|
if (isSmallWebMode)
|
||||||
|
ReaderButton(
|
||||||
|
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
||||||
|
onTap: isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await ref
|
||||||
|
.read(readerableScreenControllerProvider.notifier)
|
||||||
|
.toggleReaderView(!readerActive);
|
||||||
|
},
|
||||||
|
child: icon,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (showMainToolbarTabsCount)
|
if (showMainToolbarTabsCount)
|
||||||
TabsCountButton(
|
TabsCountButton(
|
||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
@@ -248,61 +281,76 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
),
|
),
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: !enableGestures
|
||||||
dragStartPosition.value = details.globalPosition;
|
? null
|
||||||
},
|
: (details) {
|
||||||
onHorizontalDragEnd: (details) async {
|
dragStartPosition.value = details.globalPosition;
|
||||||
final distance = dragStartPosition.value - details.globalPosition;
|
},
|
||||||
|
onHorizontalDragEnd: !enableGestures
|
||||||
|
? null
|
||||||
|
: (details) async {
|
||||||
|
final distance = dragStartPosition.value - details.globalPosition;
|
||||||
|
|
||||||
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
|
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
|
||||||
final selectedTab = ref.read(selectedTabProvider);
|
final selectedTab = ref.read(selectedTabProvider);
|
||||||
final setting = await ref
|
final setting = await ref
|
||||||
.read(generalSettingsRepositoryProvider.notifier)
|
.read(generalSettingsRepositoryProvider.notifier)
|
||||||
.fetchSettings();
|
.fetchSettings();
|
||||||
|
|
||||||
if (selectedTab != null) {
|
if (selectedTab != null) {
|
||||||
switch (setting.tabBarSwipeAction) {
|
switch (setting.tabBarSwipeAction) {
|
||||||
case TabBarSwipeAction.switchLastOpened:
|
case TabBarSwipeAction.switchLastOpened:
|
||||||
await ref
|
await ref
|
||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.selectPreviouslyOpenedTab(selectedTab);
|
.selectPreviouslyOpenedTab(selectedTab);
|
||||||
case TabBarSwipeAction.navigateOrderedTabs:
|
case TabBarSwipeAction.navigateOrderedTabs:
|
||||||
if (distance.dx < 0) {
|
if (distance.dx < 0) {
|
||||||
await ref
|
await ref
|
||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.selectPreviousTab(selectedTab);
|
.selectPreviousTab(selectedTab);
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await ref
|
||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.selectNextTab(selectedTab);
|
.selectNextTab(selectedTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
onVerticalDragStart: !enableGestures
|
||||||
},
|
? null
|
||||||
onVerticalDragStart: (details) {
|
: (details) {
|
||||||
dragStartPosition.value = details.globalPosition;
|
dragStartPosition.value = details.globalPosition;
|
||||||
},
|
},
|
||||||
onVerticalDragEnd: (details) {
|
onVerticalDragEnd: !enableGestures
|
||||||
final distance = dragStartPosition.value - details.globalPosition;
|
? null
|
||||||
|
: (details) {
|
||||||
|
final distance = dragStartPosition.value - details.globalPosition;
|
||||||
|
|
||||||
// Swipe direction for dismiss depends on toolbar position:
|
// Swipe direction for dismiss depends on toolbar position:
|
||||||
// - Bottom bar: swipe down to dismiss (positive distance.dy)
|
// - Bottom bar: swipe down to dismiss (positive distance.dy)
|
||||||
// - Top bar: swipe up to dismiss (negative distance.dy)
|
// - Top bar: swipe up to dismiss (negative distance.dy)
|
||||||
const dismissThreshold = kToolbarHeight * 0.5;
|
const dismissThreshold = kToolbarHeight * 0.5;
|
||||||
final shouldDismiss = switch (tabBarPosition) {
|
final shouldDismiss = switch (tabBarPosition) {
|
||||||
TabBarPosition.bottom =>
|
TabBarPosition.bottom =>
|
||||||
distance.dy.isNegative && distance.dy.abs() > dismissThreshold,
|
distance.dy.isNegative &&
|
||||||
TabBarPosition.top =>
|
distance.dy.abs() > dismissThreshold,
|
||||||
!distance.dy.isNegative && distance.dy.abs() > dismissThreshold,
|
TabBarPosition.top =>
|
||||||
};
|
!distance.dy.isNegative &&
|
||||||
if (shouldDismiss && ref.read(bottomSheetControllerProvider) == null) {
|
distance.dy.abs() > dismissThreshold,
|
||||||
unawaited(HapticFeedback.lightImpact());
|
};
|
||||||
ref
|
if (shouldDismiss &&
|
||||||
.read(toolbarVisibilityControllerProvider(selectedTabId).notifier)
|
ref.read(bottomSheetControllerProvider) == null) {
|
||||||
.dismiss();
|
unawaited(HapticFeedback.lightImpact());
|
||||||
}
|
ref
|
||||||
},
|
.read(
|
||||||
|
toolbarVisibilityControllerProvider(
|
||||||
|
selectedTabId,
|
||||||
|
).notifier,
|
||||||
|
)
|
||||||
|
.dismiss();
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -159,11 +159,13 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
data: (androidInfo) {
|
data: (androidInfo) {
|
||||||
if (androidInfo == null) {
|
if (androidInfo == null) {
|
||||||
// Not Android, always show GeckoView based on route
|
// 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)
|
// Android: only apply visibility fix on Android 12 and lower (API <= 31)
|
||||||
if (androidInfo.sdkInt <= 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
|
// Android 13+: always show GeckoView
|
||||||
return true;
|
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/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart';
|
||||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||||
import 'package:weblibre/presentation/widgets/selectable_chips.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/presentation/widgets/url_icon.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||||
|
|
||||||
@@ -172,86 +173,16 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
|||||||
return () => tabController.removeListener(listener);
|
return () => tabController.removeListener(listener);
|
||||||
}, [tabController]);
|
}, [tabController]);
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Animated sliding pill toggle
|
// Animated sliding pill toggle
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 12.0),
|
padding: const EdgeInsets.only(right: 12.0),
|
||||||
child: Container(
|
child: SlidingPillToggle(
|
||||||
height: 36,
|
selectedIndex: tabIndex.value,
|
||||||
decoration: BoxDecoration(
|
labels: const ['Search On This Site', 'All Providers'],
|
||||||
color: colorScheme.surfaceContainerHighest,
|
onChanged: (index) => tabController.animateTo(index),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Tab content
|
// 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: [
|
children: [
|
||||||
SettingSection(name: 'Tabs'),
|
SettingSection(name: 'Tabs'),
|
||||||
_NewTabDefaultSection(),
|
_NewTabDefaultSection(),
|
||||||
|
_SmallWebTabDefaultSection(),
|
||||||
_NewTabPositionSection(),
|
_NewTabPositionSection(),
|
||||||
_ShowContainerUiTile(),
|
_ShowContainerUiTile(),
|
||||||
_ShowIsolatedTabUiTile(),
|
_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 {
|
class _ExternalLinkHandlingSection extends HookConsumerWidget {
|
||||||
const _ExternalLinkHandlingSection();
|
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 bool urlCleanerAutoUpdate;
|
||||||
final int? urlCleanerLastCheckEpochMs;
|
final int? urlCleanerLastCheckEpochMs;
|
||||||
final bool urlCleanerLastUpdateWasAuto;
|
final bool urlCleanerLastUpdateWasAuto;
|
||||||
|
final TabType smallWebTabType;
|
||||||
final bool unshortenerEnabled;
|
final bool unshortenerEnabled;
|
||||||
final String unshortenerToken;
|
final String unshortenerToken;
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.urlCleanerAutoUpdate,
|
required this.urlCleanerAutoUpdate,
|
||||||
required this.urlCleanerLastCheckEpochMs,
|
required this.urlCleanerLastCheckEpochMs,
|
||||||
required this.urlCleanerLastUpdateWasAuto,
|
required this.urlCleanerLastUpdateWasAuto,
|
||||||
|
required this.smallWebTabType,
|
||||||
required this.unshortenerEnabled,
|
required this.unshortenerEnabled,
|
||||||
required this.unshortenerToken,
|
required this.unshortenerToken,
|
||||||
});
|
});
|
||||||
@@ -211,6 +213,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
bool? urlCleanerAutoUpdate,
|
bool? urlCleanerAutoUpdate,
|
||||||
this.urlCleanerLastCheckEpochMs,
|
this.urlCleanerLastCheckEpochMs,
|
||||||
bool? urlCleanerLastUpdateWasAuto,
|
bool? urlCleanerLastUpdateWasAuto,
|
||||||
|
TabType? smallWebTabType,
|
||||||
bool? unshortenerEnabled,
|
bool? unshortenerEnabled,
|
||||||
String? unshortenerToken,
|
String? unshortenerToken,
|
||||||
}) : themeMode = themeMode ?? ThemeMode.dark,
|
}) : themeMode = themeMode ?? ThemeMode.dark,
|
||||||
@@ -267,6 +270,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
'https://rules2.clearurls.xyz/rules.minify.hash',
|
'https://rules2.clearurls.xyz/rules.minify.hash',
|
||||||
urlCleanerAutoUpdate = urlCleanerAutoUpdate ?? false,
|
urlCleanerAutoUpdate = urlCleanerAutoUpdate ?? false,
|
||||||
urlCleanerLastUpdateWasAuto = urlCleanerLastUpdateWasAuto ?? false,
|
urlCleanerLastUpdateWasAuto = urlCleanerLastUpdateWasAuto ?? false,
|
||||||
|
smallWebTabType = smallWebTabType ?? TabType.private,
|
||||||
unshortenerEnabled = unshortenerEnabled ?? false,
|
unshortenerEnabled = unshortenerEnabled ?? false,
|
||||||
unshortenerToken = unshortenerToken ?? '';
|
unshortenerToken = unshortenerToken ?? '';
|
||||||
|
|
||||||
@@ -336,6 +340,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
urlCleanerAutoUpdate,
|
urlCleanerAutoUpdate,
|
||||||
urlCleanerLastCheckEpochMs,
|
urlCleanerLastCheckEpochMs,
|
||||||
urlCleanerLastUpdateWasAuto,
|
urlCleanerLastUpdateWasAuto,
|
||||||
|
smallWebTabType,
|
||||||
unshortenerEnabled,
|
unshortenerEnabled,
|
||||||
unshortenerToken,
|
unshortenerToken,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
|
|
||||||
GeneralSettings urlCleanerLastUpdateWasAuto(bool urlCleanerLastUpdateWasAuto);
|
GeneralSettings urlCleanerLastUpdateWasAuto(bool urlCleanerLastUpdateWasAuto);
|
||||||
|
|
||||||
|
GeneralSettings smallWebTabType(TabType smallWebTabType);
|
||||||
|
|
||||||
GeneralSettings unshortenerEnabled(bool unshortenerEnabled);
|
GeneralSettings unshortenerEnabled(bool unshortenerEnabled);
|
||||||
|
|
||||||
GeneralSettings unshortenerToken(String unshortenerToken);
|
GeneralSettings unshortenerToken(String unshortenerToken);
|
||||||
@@ -169,6 +171,7 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
bool urlCleanerAutoUpdate,
|
bool urlCleanerAutoUpdate,
|
||||||
int? urlCleanerLastCheckEpochMs,
|
int? urlCleanerLastCheckEpochMs,
|
||||||
bool urlCleanerLastUpdateWasAuto,
|
bool urlCleanerLastUpdateWasAuto,
|
||||||
|
TabType smallWebTabType,
|
||||||
bool unshortenerEnabled,
|
bool unshortenerEnabled,
|
||||||
String unshortenerToken,
|
String unshortenerToken,
|
||||||
});
|
});
|
||||||
@@ -369,6 +372,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
bool urlCleanerLastUpdateWasAuto,
|
bool urlCleanerLastUpdateWasAuto,
|
||||||
) => call(urlCleanerLastUpdateWasAuto: urlCleanerLastUpdateWasAuto);
|
) => call(urlCleanerLastUpdateWasAuto: urlCleanerLastUpdateWasAuto);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings smallWebTabType(TabType smallWebTabType) =>
|
||||||
|
call(smallWebTabType: smallWebTabType);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings unshortenerEnabled(bool unshortenerEnabled) =>
|
GeneralSettings unshortenerEnabled(bool unshortenerEnabled) =>
|
||||||
call(unshortenerEnabled: unshortenerEnabled);
|
call(unshortenerEnabled: unshortenerEnabled);
|
||||||
@@ -431,6 +438,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? urlCleanerAutoUpdate = const $CopyWithPlaceholder(),
|
Object? urlCleanerAutoUpdate = const $CopyWithPlaceholder(),
|
||||||
Object? urlCleanerLastCheckEpochMs = const $CopyWithPlaceholder(),
|
Object? urlCleanerLastCheckEpochMs = const $CopyWithPlaceholder(),
|
||||||
Object? urlCleanerLastUpdateWasAuto = const $CopyWithPlaceholder(),
|
Object? urlCleanerLastUpdateWasAuto = const $CopyWithPlaceholder(),
|
||||||
|
Object? smallWebTabType = const $CopyWithPlaceholder(),
|
||||||
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
@@ -693,6 +701,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.urlCleanerLastUpdateWasAuto
|
? _value.urlCleanerLastUpdateWasAuto
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: urlCleanerLastUpdateWasAuto as bool,
|
: urlCleanerLastUpdateWasAuto as bool,
|
||||||
|
smallWebTabType:
|
||||||
|
smallWebTabType == const $CopyWithPlaceholder() ||
|
||||||
|
smallWebTabType == null
|
||||||
|
? _value.smallWebTabType
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: smallWebTabType as TabType,
|
||||||
unshortenerEnabled:
|
unshortenerEnabled:
|
||||||
unshortenerEnabled == const $CopyWithPlaceholder() ||
|
unshortenerEnabled == const $CopyWithPlaceholder() ||
|
||||||
unshortenerEnabled == null
|
unshortenerEnabled == null
|
||||||
@@ -808,6 +822,10 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
urlCleanerLastCheckEpochMs: (json['urlCleanerLastCheckEpochMs'] as num?)
|
urlCleanerLastCheckEpochMs: (json['urlCleanerLastCheckEpochMs'] as num?)
|
||||||
?.toInt(),
|
?.toInt(),
|
||||||
urlCleanerLastUpdateWasAuto: json['urlCleanerLastUpdateWasAuto'] as bool?,
|
urlCleanerLastUpdateWasAuto: json['urlCleanerLastUpdateWasAuto'] as bool?,
|
||||||
|
smallWebTabType: $enumDecodeNullable(
|
||||||
|
_$TabTypeEnumMap,
|
||||||
|
json['smallWebTabType'],
|
||||||
|
),
|
||||||
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
||||||
unshortenerToken: json['unshortenerToken'] as String?,
|
unshortenerToken: json['unshortenerToken'] as String?,
|
||||||
);
|
);
|
||||||
@@ -870,6 +888,7 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate,
|
'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate,
|
||||||
'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs,
|
'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs,
|
||||||
'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto,
|
'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto,
|
||||||
|
'smallWebTabType': _$TabTypeEnumMap[instance.smallWebTabType]!,
|
||||||
'unshortenerEnabled': instance.unshortenerEnabled,
|
'unshortenerEnabled': instance.unshortenerEnabled,
|
||||||
'unshortenerToken': instance.unshortenerToken,
|
'unshortenerToken': instance.unshortenerToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ final class UserDatabaseProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$userDatabaseHash() => r'ebc37c5e9604e02f820c733b19f8b7678b335493';
|
String _$userDatabaseHash() => r'097505438c098252a322e6d0e49f885d67ebe898';
|
||||||
|
|
||||||
@ProviderFor(riverpodDatabaseStorage)
|
@ProviderFor(riverpodDatabaseStorage)
|
||||||
final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._();
|
final riverpodDatabaseStorageProvider = RiverpodDatabaseStorageProvider._();
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
|||||||
?.readAs(DriftSqlType.int, db.typeMapping),
|
?.readAs(DriftSqlType.int, db.typeMapping),
|
||||||
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
|
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
|
||||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||||
|
'smallWebTabType': settings['smallWebTabType']?.readAs(
|
||||||
|
DriftSqlType.string,
|
||||||
|
db.typeMapping,
|
||||||
|
),
|
||||||
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
|
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$generalSettingsRepositoryHash() =>
|
String _$generalSettingsRepositoryHash() =>
|
||||||
r'04764c3b9a71aec077ab2b7894d117f993678b2e';
|
r'ae071b136a14d1f907635579f134608023d81766';
|
||||||
|
|
||||||
abstract class _$GeneralSettingsRepository
|
abstract class _$GeneralSettingsRepository
|
||||||
extends $StreamNotifier<GeneralSettings> {
|
extends $StreamNotifier<GeneralSettings> {
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ final class FeedDatabaseProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$feedDatabaseHash() => r'182dd4203290835fc2188627db08fb0491bd91b2';
|
String _$feedDatabaseHash() => r'1ecae87a3de5b2d43136fdb73411727b0b217621';
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SlidingPillToggle extends StatelessWidget {
|
||||||
|
final int selectedIndex;
|
||||||
|
final List<String> labels;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
const SlidingPillToggle({
|
||||||
|
super.key,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.labels,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
alignment: Alignment(
|
||||||
|
-1.0 + (2.0 * selectedIndex / (labels.length - 1)),
|
||||||
|
0.0,
|
||||||
|
),
|
||||||
|
duration: disableAnimations
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 1.0 / labels.length,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < labels.length; i++)
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => onChanged(i),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
labels[i],
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: i == selectedIndex
|
||||||
|
? colorScheme.onPrimary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: i == selectedIndex
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,7 @@ flutter:
|
|||||||
- assets/preferences/
|
- assets/preferences/
|
||||||
- assets/legal/
|
- assets/legal/
|
||||||
- assets/quotes/
|
- assets/quotes/
|
||||||
|
- assets/small_web/
|
||||||
fonts:
|
fonts:
|
||||||
- family: TorIcons
|
- family: TorIcons
|
||||||
fonts:
|
fonts:
|
||||||
|
|||||||
Reference in New Issue
Block a user