small web feature initial

This commit is contained in:
Fabian Freund
2026-03-23 11:13:39 +01:00
parent 1e56bb3e3a
commit b9669635d9
61 changed files with 10455 additions and 233 deletions
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'small_web_mode_controller.g.dart';
@Riverpod(keepAlive: true)
class SmallWebModeController extends _$SmallWebModeController {
String? _smallWebTabId;
@override
String? build() {
ref.listen(selectedTabProvider, (previous, next) {
_handleSelectedTabChange(next);
});
ref.listen(tabListProvider, (previous, next) {
if (_smallWebTabId != null && !next.value.contains(_smallWebTabId)) {
_smallWebTabId = null;
state = null;
}
});
return null;
}
void _handleSelectedTabChange(String? selectedTabId) {
if (selectedTabId == _smallWebTabId && _smallWebTabId != null) {
state = _smallWebTabId;
} else if (state != null) {
state = null;
}
}
Future<void> enter() async {
if (state != null) return;
if (_smallWebTabId != null &&
ref.read(tabListProvider).value.contains(_smallWebTabId)) {
await ref.read(tabRepositoryProvider.notifier).selectTab(_smallWebTabId!);
return;
}
final settings = ref.read(generalSettingsWithDefaultsProvider);
final tabMode = TabMode.fromTabType(settings.smallWebTabType);
final newTabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(tabMode: tabMode, selectTab: true);
if (!ref.mounted) return;
_smallWebTabId = newTabId;
state = newTabId;
await ref.read(smallWebSessionControllerProvider.notifier).discover();
}
Future<void> exit() async {
if (_smallWebTabId == null) return;
final tabId = _smallWebTabId!;
_smallWebTabId = null;
state = null;
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'small_web_mode_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SmallWebModeController)
final smallWebModeControllerProvider = SmallWebModeControllerProvider._();
final class SmallWebModeControllerProvider
extends $NotifierProvider<SmallWebModeController, String?> {
SmallWebModeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebModeControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebModeControllerHash();
@$internal
@override
SmallWebModeController create() => SmallWebModeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$smallWebModeControllerHash() =>
r'26fb84c9ce60562738f7d9523f74aebfb19ad84b';
abstract class _$SmallWebModeController extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,327 @@
// ignore_for_file: avoid_redundant_argument_values
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
import 'package:weblibre/features/user/data/providers.dart';
part 'small_web_session_controller.g.dart';
@CopyWith()
@JsonSerializable()
class SmallWebSessionState with FastEquatable {
final SmallWebSourceKind sourceKind;
final KagiSmallWebMode? mode;
final String? currentCategory;
final String? currentItemId;
final Uri? currentItemUrl;
final Uri? currentConsoleUrl;
@JsonKey(includeToJson: false, includeFromJson: false)
final String? infoMessage;
static KagiSmallWebMode? _defaultModeForSourceKind(
SmallWebSourceKind sourceKind,
) {
return sourceKind == SmallWebSourceKind.wander
? null
: KagiSmallWebMode.web;
}
SmallWebSessionState({
this.sourceKind = SmallWebSourceKind.kagi,
this.mode,
this.currentCategory,
this.currentItemId,
this.currentItemUrl,
this.currentConsoleUrl,
this.infoMessage,
});
factory SmallWebSessionState.fromJson(Map<String, dynamic> json) =>
_$SmallWebSessionStateFromJson(json);
Map<String, dynamic> toJson() => _$SmallWebSessionStateToJson(this);
factory SmallWebSessionState.initial(SmallWebSourceKind sourceKind) {
return SmallWebSessionState(
sourceKind: sourceKind,
mode: _defaultModeForSourceKind(sourceKind),
);
}
factory SmallWebSessionState.forSourceKind({
required SmallWebSourceKind sourceKind,
Uri? currentConsoleUrl,
}) {
return SmallWebSessionState(
sourceKind: sourceKind,
mode: _defaultModeForSourceKind(sourceKind),
currentConsoleUrl: sourceKind == SmallWebSourceKind.wander
? currentConsoleUrl
: null,
);
}
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [
sourceKind,
mode,
currentCategory,
currentItemId,
currentItemUrl,
currentConsoleUrl,
infoMessage,
];
}
@Riverpod(keepAlive: true)
class SmallWebSessionController extends _$SmallWebSessionController {
@override
AsyncValue<SmallWebSessionState> build() {
final persistFuture = persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'SmallWebSessionState',
encode: (state) => jsonEncode(
(state.value ?? SmallWebSessionState.initial(SmallWebSourceKind.kagi))
.toJson(),
),
decode: (encoded) {
try {
final decoded = SmallWebSessionState.fromJson(
jsonDecode(encoded) as Map<String, dynamic>,
);
return AsyncData(decoded);
} catch (_) {
return AsyncData(
SmallWebSessionState.initial(SmallWebSourceKind.kagi),
);
}
},
);
listenSelf((previous, next) {
next.whenData((data) async {
if (data.currentItemUrl != null &&
data.currentItemUrl != previous?.value?.currentItemUrl) {
final smallWebTabId = ref.read(smallWebModeControllerProvider);
var state = ref.read(tabStatesProvider)[smallWebTabId];
if (smallWebTabId != null) {
if (state == null) {
for (var i = 0; i < 25; i++) {
await Future.delayed(const Duration(milliseconds: 100));
state = ref.read(tabStatesProvider)[smallWebTabId];
if (state != null) {
break;
}
}
}
if (state == null) return;
await ref
.read(tabSessionProvider(tabId: smallWebTabId).notifier)
.loadUrl(url: data.currentItemUrl!);
}
}
});
});
if (persistFuture.future != null) {
return const AsyncLoading();
}
return AsyncData(SmallWebSessionState.initial(SmallWebSourceKind.kagi));
}
void setSourceKind(SmallWebSourceKind kind) {
state = AsyncData(
SmallWebSessionState.forSourceKind(
sourceKind: kind,
currentConsoleUrl: kind == SmallWebSourceKind.wander
? state.value?.currentConsoleUrl
: null,
),
);
}
void setMode(KagiSmallWebMode mode) {
if (state.hasValue) {
state = AsyncData(
state.requireValue.copyWith(
mode: mode,
currentCategory: null,
currentItemId: null,
currentItemUrl: null,
infoMessage: null,
),
);
}
}
void setCategory(String? category) {
if (state.hasValue) {
state = AsyncData(
state.requireValue.copyWith(
currentCategory: category,
infoMessage: null,
),
);
}
}
Future<void> discover({bool forceNewConsole = false}) async {
if (state.isLoading) return;
if (!state.hasValue) return;
final session = state.requireValue.copyWith(infoMessage: null);
state = const AsyncLoading<SmallWebSessionState>();
try {
final discoverService = await ref.read(
smallWebDiscoverServiceProvider.future,
);
if (session.sourceKind == SmallWebSourceKind.wander) {
final result = await discoverService.discoverWander(
currentConsoleUrl: session.currentConsoleUrl,
forceNewConsole: forceNewConsole,
);
if (result != null) {
state = AsyncData(
session.copyWith(
currentItemId: result.item.id,
currentItemUrl: result.item.url,
currentConsoleUrl: result.consoleUrl,
),
);
return;
}
} else {
final item = await discoverService.discoverKagi(
mode: session.mode!,
category: session.currentCategory,
);
if (item != null) {
state = AsyncData(
session.copyWith(currentItemId: item.id, currentItemUrl: item.url),
);
return;
}
}
state = AsyncData(
session.copyWith(
infoMessage: 'No new items found. Try a different mode or category.',
),
);
} catch (e, st) {
logger.e('Discovery failed', error: e, stackTrace: st);
state = AsyncError<SmallWebSessionState>(
'Discovery failed. Please try again.',
st,
);
}
}
void selectConsole(Uri consoleUrl) {
if (state.hasValue) {
state = AsyncData(
state.requireValue.copyWith(
currentConsoleUrl: consoleUrl,
currentItemId: null,
currentItemUrl: null,
),
);
}
}
Future<void> updateTitleFromTab(String title, {required Uri? tabUrl}) async {
if (state.hasValue) {
final session = state.requireValue;
if (session.sourceKind != SmallWebSourceKind.wander) return;
final itemId = session.currentItemId;
final itemUrl = session.currentItemUrl;
if (itemId == null || title.isEmpty) return;
if (tabUrl == null || itemUrl == null || tabUrl != itemUrl) return;
final discoverService = await ref.read(
smallWebDiscoverServiceProvider.future,
);
await discoverService.updateItemTitle(itemId, title);
}
}
Future<void> revisit({
required String itemId,
required Uri url,
required SmallWebSourceKind sourceKind,
required KagiSmallWebMode? mode,
Uri? consoleUrl,
}) async {
if (state.hasValue) {
final discoverService = await ref.read(
smallWebDiscoverServiceProvider.future,
);
await discoverService.recordVisit(
itemId: itemId,
sourceKind: sourceKind,
mode: mode,
consoleUrl: consoleUrl,
);
state = AsyncData(
state.requireValue.copyWith(
currentItemId: itemId,
currentItemUrl: url,
currentConsoleUrl: consoleUrl,
infoMessage: null,
),
);
}
}
}
@@ -0,0 +1,254 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'small_web_session_controller.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$SmallWebSessionStateCWProxy {
SmallWebSessionState sourceKind(SmallWebSourceKind sourceKind);
SmallWebSessionState mode(KagiSmallWebMode? mode);
SmallWebSessionState currentCategory(String? currentCategory);
SmallWebSessionState currentItemId(String? currentItemId);
SmallWebSessionState currentItemUrl(Uri? currentItemUrl);
SmallWebSessionState currentConsoleUrl(Uri? currentConsoleUrl);
SmallWebSessionState infoMessage(String? infoMessage);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SmallWebSessionState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SmallWebSessionState(...).copyWith(id: 12, name: "My name")
/// ```
SmallWebSessionState call({
SmallWebSourceKind sourceKind,
KagiSmallWebMode? mode,
String? currentCategory,
String? currentItemId,
Uri? currentItemUrl,
Uri? currentConsoleUrl,
String? infoMessage,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfSmallWebSessionState.copyWith(...)` or call `instanceOfSmallWebSessionState.copyWith.fieldName(value)` for a single field.
class _$SmallWebSessionStateCWProxyImpl
implements _$SmallWebSessionStateCWProxy {
const _$SmallWebSessionStateCWProxyImpl(this._value);
final SmallWebSessionState _value;
@override
SmallWebSessionState sourceKind(SmallWebSourceKind sourceKind) =>
call(sourceKind: sourceKind);
@override
SmallWebSessionState mode(KagiSmallWebMode? mode) => call(mode: mode);
@override
SmallWebSessionState currentCategory(String? currentCategory) =>
call(currentCategory: currentCategory);
@override
SmallWebSessionState currentItemId(String? currentItemId) =>
call(currentItemId: currentItemId);
@override
SmallWebSessionState currentItemUrl(Uri? currentItemUrl) =>
call(currentItemUrl: currentItemUrl);
@override
SmallWebSessionState currentConsoleUrl(Uri? currentConsoleUrl) =>
call(currentConsoleUrl: currentConsoleUrl);
@override
SmallWebSessionState infoMessage(String? infoMessage) =>
call(infoMessage: infoMessage);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SmallWebSessionState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SmallWebSessionState(...).copyWith(id: 12, name: "My name")
/// ```
SmallWebSessionState call({
Object? sourceKind = const $CopyWithPlaceholder(),
Object? mode = const $CopyWithPlaceholder(),
Object? currentCategory = const $CopyWithPlaceholder(),
Object? currentItemId = const $CopyWithPlaceholder(),
Object? currentItemUrl = const $CopyWithPlaceholder(),
Object? currentConsoleUrl = const $CopyWithPlaceholder(),
Object? infoMessage = const $CopyWithPlaceholder(),
}) {
return SmallWebSessionState(
sourceKind:
sourceKind == const $CopyWithPlaceholder() || sourceKind == null
? _value.sourceKind
// ignore: cast_nullable_to_non_nullable
: sourceKind as SmallWebSourceKind,
mode: mode == const $CopyWithPlaceholder()
? _value.mode
// ignore: cast_nullable_to_non_nullable
: mode as KagiSmallWebMode?,
currentCategory: currentCategory == const $CopyWithPlaceholder()
? _value.currentCategory
// ignore: cast_nullable_to_non_nullable
: currentCategory as String?,
currentItemId: currentItemId == const $CopyWithPlaceholder()
? _value.currentItemId
// ignore: cast_nullable_to_non_nullable
: currentItemId as String?,
currentItemUrl: currentItemUrl == const $CopyWithPlaceholder()
? _value.currentItemUrl
// ignore: cast_nullable_to_non_nullable
: currentItemUrl as Uri?,
currentConsoleUrl: currentConsoleUrl == const $CopyWithPlaceholder()
? _value.currentConsoleUrl
// ignore: cast_nullable_to_non_nullable
: currentConsoleUrl as Uri?,
infoMessage: infoMessage == const $CopyWithPlaceholder()
? _value.infoMessage
// ignore: cast_nullable_to_non_nullable
: infoMessage as String?,
);
}
}
extension $SmallWebSessionStateCopyWith on SmallWebSessionState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfSmallWebSessionState.copyWith(...)` or `instanceOfSmallWebSessionState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$SmallWebSessionStateCWProxy get copyWith =>
_$SmallWebSessionStateCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SmallWebSessionState _$SmallWebSessionStateFromJson(
Map<String, dynamic> json,
) => SmallWebSessionState(
sourceKind:
$enumDecodeNullable(_$SmallWebSourceKindEnumMap, json['sourceKind']) ??
SmallWebSourceKind.kagi,
mode: $enumDecodeNullable(_$KagiSmallWebModeEnumMap, json['mode']),
currentCategory: json['currentCategory'] as String?,
currentItemId: json['currentItemId'] as String?,
currentItemUrl: json['currentItemUrl'] == null
? null
: Uri.parse(json['currentItemUrl'] as String),
currentConsoleUrl: json['currentConsoleUrl'] == null
? null
: Uri.parse(json['currentConsoleUrl'] as String),
);
Map<String, dynamic> _$SmallWebSessionStateToJson(
SmallWebSessionState instance,
) => <String, dynamic>{
'sourceKind': _$SmallWebSourceKindEnumMap[instance.sourceKind]!,
'mode': _$KagiSmallWebModeEnumMap[instance.mode],
'currentCategory': instance.currentCategory,
'currentItemId': instance.currentItemId,
'currentItemUrl': instance.currentItemUrl?.toString(),
'currentConsoleUrl': instance.currentConsoleUrl?.toString(),
};
const _$SmallWebSourceKindEnumMap = {
SmallWebSourceKind.kagi: 'kagi',
SmallWebSourceKind.wander: 'wander',
};
const _$KagiSmallWebModeEnumMap = {
KagiSmallWebMode.web: 'web',
KagiSmallWebMode.appreciated: 'appreciated',
KagiSmallWebMode.videos: 'videos',
KagiSmallWebMode.code: 'code',
KagiSmallWebMode.comics: 'comics',
};
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SmallWebSessionController)
final smallWebSessionControllerProvider = SmallWebSessionControllerProvider._();
final class SmallWebSessionControllerProvider
extends
$NotifierProvider<
SmallWebSessionController,
AsyncValue<SmallWebSessionState>
> {
SmallWebSessionControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebSessionControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebSessionControllerHash();
@$internal
@override
SmallWebSessionController create() => SmallWebSessionController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<SmallWebSessionState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<SmallWebSessionState>>(
value,
),
);
}
}
String _$smallWebSessionControllerHash() =>
r'90680807b38de577e3322941b47792952d66428a';
abstract class _$SmallWebSessionController
extends $Notifier<AsyncValue<SmallWebSessionState>> {
AsyncValue<SmallWebSessionState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<SmallWebSessionState>,
AsyncValue<SmallWebSessionState>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<SmallWebSessionState>,
AsyncValue<SmallWebSessionState>
>,
AsyncValue<SmallWebSessionState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,215 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
class SmallWebAttributionAction {
final String label;
final IconData icon;
final Uri uri;
const SmallWebAttributionAction({
required this.label,
required this.icon,
required this.uri,
});
}
class SmallWebAttributionData {
final IconData icon;
final String title;
final String? badgeLabel;
final String description;
final String attributionLine;
final String? metadataLine;
final List<SmallWebAttributionAction> actions;
const SmallWebAttributionData({
required this.icon,
required this.title,
this.badgeLabel,
required this.description,
required this.attributionLine,
required this.actions,
this.metadataLine,
});
factory SmallWebAttributionData.forSelection({
required SmallWebSourceKind sourceKind,
KagiSmallWebMode? mode,
}) {
return switch (sourceKind) {
SmallWebSourceKind.kagi => SmallWebAttributionData._forKagi(
mode ?? KagiSmallWebMode.web,
),
SmallWebSourceKind.wander => SmallWebAttributionData._forWander(),
};
}
factory SmallWebAttributionData._forKagi(KagiSmallWebMode mode) {
final commonActions = [
SmallWebAttributionAction(
label: 'Blog Post',
icon: Icons.article_outlined,
uri: Uri.https('blog.kagi.com', '/small-web'),
),
SmallWebAttributionAction(
label: 'GitHub',
icon: Icons.code,
uri: Uri.https('github.com', '/kagisearch/smallweb'),
),
];
final description = switch (mode) {
KagiSmallWebMode.web =>
'Kagi Small Web surfaces recent posts from personal sites and blogs by individual authors across the small web.',
KagiSmallWebMode.appreciated =>
'This Kagi Small Web mode highlights appreciated posts from the small web as curated by the open-source project.',
KagiSmallWebMode.videos =>
'This Kagi Small Web mode focuses on video posts from smaller independent creators and curated channel seeds.',
KagiSmallWebMode.code =>
'This Kagi Small Web mode focuses on code-oriented posts from personal sites and other small web sources.',
KagiSmallWebMode.comics =>
'This Kagi Small Web mode focuses on comics and illustrated posts surfaced through the Small Web project.',
};
return SmallWebAttributionData(
icon: Icons.travel_explore,
title: 'Kagi Small Web',
badgeLabel: mode.label,
description: description,
attributionLine: 'By Kagi Search - open source under the MIT License.',
actions: [...commonActions],
);
}
factory SmallWebAttributionData._forWander() {
return SmallWebAttributionData(
icon: Icons.dns,
title: 'Wander',
description:
'Wander is a network of personal websites connected through shared consoles that help people browse pages across the wider Wander community.',
attributionLine: 'By Susam Pal - open source under the MIT License.',
actions: [
SmallWebAttributionAction(
label: 'Project',
icon: Icons.public,
uri: Uri.https('codeberg.org', '/susam/wander'),
),
SmallWebAttributionAction(
label: 'Setup your Console',
icon: Icons.forum_outlined,
uri: Uri.https('codeberg.org', '/susam/wander#install'),
),
],
);
}
}
class SmallWebAttributionCard extends StatelessWidget {
final SmallWebAttributionData data;
final ValueChanged<Uri> onOpenUri;
final bool compact;
const SmallWebAttributionCard({
super.key,
required this.data,
required this.onOpenUri,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final headerStyle = compact
? theme.textTheme.titleSmall
: theme.textTheme.titleMedium;
final bodyStyle = compact
? theme.textTheme.bodySmall
: theme.textTheme.bodyMedium;
final spacing = compact ? 8.0 : 12.0;
return Card(
color: colorScheme.surfaceContainerHigh,
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
data.icon,
size: compact ? 20 : 22,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
data.title,
style: headerStyle?.copyWith(color: colorScheme.primary),
),
),
if (data.badgeLabel != null)
Chip(
visualDensity: VisualDensity.compact,
label: Text(data.badgeLabel!),
),
],
),
SizedBox(height: spacing),
Text(data.description, style: bodyStyle),
SizedBox(height: spacing),
Text(
data.attributionLine,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (data.metadataLine case final metadataLine?) ...[
const SizedBox(height: 4),
Text(
metadataLine,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
SizedBox(height: spacing),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final action in data.actions)
ActionChip(
avatar: Icon(action.icon, size: 18),
label: Text(action.label),
onPressed: () => onOpenUri(action.uri),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
Future<void> openSmallWebAttributionUri(BuildContext context, Uri uri) async {
final container = ProviderScope.containerOf(context, listen: false);
final router = await container.read(routerProvider.future);
if (context.mounted) {
Navigator.of(context).pop();
}
await container
.read(tabRepositoryProvider.notifier)
.addTab(url: uri, tabMode: TabMode.regular, selectTab: true);
router.go(const BrowserRoute().location);
}
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
// What words does the wanderer whisper?
const _incantations = [
'Discover',
'Explore',
'Uncover',
'Venture',
'Stumble',
'Unearth',
'Surface',
'Traverse',
'Journey',
'Wander',
'Drift',
'Roam',
'Seek',
'Delve',
'Forage',
'Glimpse',
'Meander',
'Rummage',
'Saunter',
'Unveil',
'Excavate',
'Fathom',
'Unravel',
'Summon',
'Conjure',
'Invoke',
'Divine',
'Beckon',
'Emerge',
'Ascend',
'Leap',
'Untangle',
'Illuminate',
'Whisper',
'Ponder',
'Stray',
'Ramble',
'Chart',
'Prowl',
'Unwind',
'Foray',
'Plunge',
'Scout',
'Pilgrimage',
'Gallivant',
'Sift',
'Decode',
'Unfurl',
'Kindle',
'Peruse',
'Dabble',
'Peer',
'Freefall',
'Vault',
'Burrow',
'Glean',
'Transmute',
'Decipher',
'Unmask',
'Plumb',
'Unseal',
'Ignite',
'Evoke',
'Manifest',
'Enchant',
'Entrance',
'Lure',
'Coax',
'Entice',
'Eclipse',
'Transcend',
'Migrate',
'Flit',
'Tumble',
'Cascade',
'Zigzag',
'Spiral',
'Orbit',
'Converge',
'Gravitate',
'Bloom',
'Unfold',
'Blossom',
'Awaken',
'Weave',
'Channel',
'Envision',
'Muse',
'Wonder',
'Marvel',
'Dream',
'Reflect',
'Behold',
'Witness',
'Unlock',
'Pry',
'Release',
'Glide',
'Soar',
'Slip',
'Navigate',
'Bound',
'Sweep',
'Phase',
'Warp',
'Shift',
'Blink',
'Tiptoe',
'Breeze',
'Descend',
'Immerse',
'Wade',
'Launch',
'Spark',
'Morph',
];
final _random = Random();
class SmallWebBottomBar extends HookConsumerWidget {
final bool isLoading;
final Uri? currentTabUrl;
final String? currentTabTitle;
final VoidCallback onDiscover;
final VoidCallback onMenuTap;
final VoidCallback onExit;
const SmallWebBottomBar({
super.key,
required this.isLoading,
required this.currentTabUrl,
required this.currentTabTitle,
required this.onDiscover,
required this.onMenuTap,
required this.onExit,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = useState(_incantations.first);
final tabUrl = currentTabUrl;
final bookmarkable = tabUrl != null;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
final isBookmarked = existingGuids.isNotEmpty;
return SizedBox(
height: 56,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Menu',
onPressed: onMenuTap,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: FilledButton.icon(
onPressed: isLoading
? null
: () {
onDiscover();
label.value =
_incantations[_random.nextInt(
_incantations.length,
)];
},
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.explore, size: 20),
label: Text(label.value),
),
),
),
IconButton(
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border),
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: !bookmarkable
? null
: () async {
if (isBookmarked) {
for (final guid in existingGuids) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(guid);
}
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark removed');
}
} else {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addBookmark(
parentGuid: BookmarkRoot.mobile.id,
url: tabUrl,
title: currentTabTitle ?? tabUrl.host,
);
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark added');
}
}
},
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Exit Small Web',
onPressed: onExit,
),
],
),
);
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_bottom_bar.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_menu_sheet.dart';
class SmallWebBrowserOverlay extends HookConsumerWidget {
const SmallWebBrowserOverlay({super.key});
static const barHeight = 56.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionAsync = ref.watch(smallWebSessionControllerProvider);
final selectedTabId = ref.watch(selectedTabProvider);
final tabState = ref.watch(tabStateProvider(selectedTabId));
final tabUrl = tabState?.url;
ref.listen(
tabStateProvider(selectedTabId).select((value) => value?.title),
(prev, title) async {
if (title != null && title.isNotEmpty && prev != title) {
await ref
.read(smallWebSessionControllerProvider.notifier)
.updateTitleFromTab(title, tabUrl: tabUrl);
}
},
);
ref.listen(smallWebSessionControllerProvider, (prev, next) {
final error = next.asError?.error;
final previousError = prev?.asError?.error;
if (error != null && error != previousError && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
});
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottomPadding),
child: SmallWebBottomBar(
isLoading: sessionAsync.isLoading,
currentTabUrl: tabUrl,
currentTabTitle: tabState?.titleOrAuthority,
onDiscover: () =>
ref.read(smallWebSessionControllerProvider.notifier).discover(),
onMenuTap: () => showSmallWebMenuSheet(context),
onExit: () => ref.read(smallWebModeControllerProvider.notifier).exit(),
),
);
}
}
@@ -0,0 +1,309 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/small_web/data/database/definitions.drift.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/data/providers.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class SmallWebHistoryHeader extends ConsumerWidget {
final SmallWebSourceKind sourceKind;
final KagiSmallWebMode? mode;
const SmallWebHistoryHeader({
super.key,
required this.sourceKind,
required this.mode,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final modeLabel = mode?.label;
return Row(
children: [
Text('Recent Discoveries', style: theme.textTheme.titleSmall),
const Spacer(),
PopupMenuButton<_ClearAction>(
icon: Icon(
Icons.more_vert,
size: 20,
color: colorScheme.onSurfaceVariant,
),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
offset: const Offset(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (action) async {
switch (action) {
case _ClearAction.clearMode:
await ref
.read(smallWebDatabaseProvider)
.smallWebVisitDao
.deleteVisitsBySourceAndMode(
sourceKind: sourceKind,
mode: mode,
);
case _ClearAction.clearAll:
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear all discoveries?'),
content: const Text(
'This will permanently remove all recent discovery history across every mode and source.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear All'),
),
],
),
);
if (confirmed == true) {
await ref
.read(smallWebDatabaseProvider)
.smallWebVisitDao
.deleteAllVisits();
}
}
},
itemBuilder: (context) => [
if (modeLabel != null)
PopupMenuItem(
value: _ClearAction.clearMode,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_sweep, size: 20),
title: Text('Clear $modeLabel'),
),
),
PopupMenuItem(
value: _ClearAction.clearAll,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.delete_forever,
size: 20,
color: colorScheme.error,
),
title: Text(
'Clear all discoveries',
style: TextStyle(color: colorScheme.error),
),
),
),
],
),
],
);
}
}
enum _ClearAction { clearMode, clearAll }
class SmallWebHistoryList extends HookConsumerWidget {
static const _initialCount = 10;
final SmallWebSourceKind sourceKind;
final KagiSmallWebMode? mode;
const SmallWebHistoryList({
super.key,
required this.sourceKind,
required this.mode,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final visitsAsync = ref.watch(
smallWebRecentVisitsProvider(sourceKind, mode),
);
final expanded = useState(false);
return visitsAsync.when(
data: (visits) {
if (visits.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'No discoveries yet.\nTap Discover to start exploring!',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
final hasMore = visits.length > _initialCount;
final visibleCount = expanded.value || !hasMore
? visits.length
: _initialCount;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: visibleCount,
itemBuilder: (context, index) {
final visit = visits[index];
return _HistoryListItem(
visit: visit,
sourceKind: sourceKind,
mode: mode,
);
},
),
if (hasMore && !expanded.value)
TextButton(
onPressed: () => expanded.value = true,
child: Text('Show ${visits.length - _initialCount} more'),
),
],
);
},
error: (error, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(child: Text('Failed to load history: $error')),
),
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
),
);
}
}
class _HistoryListItem extends ConsumerWidget {
final GetRecentVisitsResult visit;
final SmallWebSourceKind sourceKind;
final KagiSmallWebMode? mode;
const _HistoryListItem({
required this.visit,
required this.sourceKind,
required this.mode,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
const borderRadius = BorderRadius.all(Radius.circular(12));
return Container(
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: const BoxDecoration(borderRadius: borderRadius),
child: Material(
color: Colors.transparent,
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: borderRadius,
onTap: () async {
await ref
.read(smallWebSessionControllerProvider.notifier)
.revisit(
itemId: visit.itemId,
url: visit.url,
sourceKind: sourceKind,
mode: mode,
consoleUrl: visit.consoleUrl,
);
if (context.mounted) Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(left: 12, top: 10, bottom: 10),
child: Row(
children: [
UrlIcon([visit.url], iconSize: 32),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
visit.title ?? visit.domain,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
UriBreadcrumb(
uri: visit.url,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () async {
await ref
.read(smallWebDatabaseProvider)
.smallWebVisitDao
.deleteVisitById(visit.id);
},
icon: Icon(
Icons.close,
size: 20,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,897 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/small_web/data/models/kagi_category.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/data/providers.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_attribution_card.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_attribution_navigation.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_history_list.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_mode_chips.dart';
import 'package:weblibre/features/small_web/presentation/widgets/wander_console_sheet.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
Future<void> showSmallWebMenuSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) => const _SmallWebMenuSheet(),
);
}
class _SmallWebMenuSheet extends ConsumerWidget {
const _SmallWebMenuSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionAsync = ref.watch(smallWebSessionControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.85,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
height: 4,
width: 40,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.explore, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Small Web',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
sessionAsync.when(
data: (session) => _SourceKindChip(
sourceKind: session.sourceKind,
onChanged: (kind) {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setSourceKind(kind);
// await notifier.discover();
},
),
loading: () => _SourceKindChip(
sourceKind: SmallWebSourceKind.kagi,
onChanged: (_) {},
),
error: (_, _) => IconButton(
onPressed: ref
.read(smallWebSessionControllerProvider.notifier)
.discover,
icon: const Icon(Icons.refresh),
tooltip: 'Retry',
),
),
],
),
),
Expanded(
child: sessionAsync.when(
data: (session) => _SmallWebMenuContent(
scrollController: scrollController,
session: session,
),
loading: () =>
_SmallWebMenuLoading(scrollController: scrollController),
error: (error, _) => _SmallWebMenuError(
scrollController: scrollController,
error: error,
onRetry: ref
.read(smallWebSessionControllerProvider.notifier)
.discover,
),
),
),
],
);
},
);
}
}
class _SourceKindChip extends StatelessWidget {
final SmallWebSourceKind sourceKind;
final ValueChanged<SmallWebSourceKind> onChanged;
const _SourceKindChip({required this.sourceKind, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<SmallWebSourceKind>(
initialValue: sourceKind,
onSelected: (kind) {
if (kind != sourceKind) onChanged(kind);
},
offset: const Offset(0, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (context) => [
for (final kind in SmallWebSourceKind.values)
PopupMenuItem(
value: kind,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(kind.icon, size: 20),
title: Text(kind.label),
subtitle: Text(
kind.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
trailing: kind == sourceKind
? Icon(Icons.check, size: 20, color: colorScheme.primary)
: null,
),
),
],
child: Chip(
avatar: Icon(sourceKind.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(sourceKind.label),
const SizedBox(width: 2),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
visualDensity: VisualDensity.compact,
),
);
}
}
class _SmallWebMenuContent extends ConsumerWidget {
final ScrollController scrollController;
final SmallWebSessionState session;
const _SmallWebMenuContent({
required this.scrollController,
required this.session,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
if (session.sourceKind == SmallWebSourceKind.kagi) ...[
SmallWebModeChips(
currentMode: session.mode,
isLoading: false,
onModeSelected: (mode) {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setMode(mode);
// await notifier.discover();
},
),
const SizedBox(height: 4),
const Divider(height: 1),
],
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: _buildContentForMode(context, ref, scrollController),
),
),
],
);
}
Widget _buildContentForMode(
BuildContext context,
WidgetRef ref,
ScrollController scrollController,
) {
if (session.sourceKind == SmallWebSourceKind.kagi &&
session.mode == KagiSmallWebMode.web) {
return _WebCategoriesPanel(
key: const ValueKey('web_panel'),
scrollController: scrollController,
session: session,
);
}
if (session.sourceKind == SmallWebSourceKind.kagi &&
session.mode != null &&
session.mode != KagiSmallWebMode.web) {
return _ModeContextPanel(
key: ValueKey('mode_${session.mode!.name}'),
scrollController: scrollController,
session: session,
mode: session.mode!,
);
}
return _DefaultContentPanel(
key: const ValueKey('default_panel'),
scrollController: scrollController,
session: session,
);
}
}
class _WebCategoriesPanel extends ConsumerWidget {
final ScrollController scrollController;
final SmallWebSessionState session;
const _WebCategoriesPanel({
super.key,
required this.scrollController,
required this.session,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final categoriesAsync = ref.watch(kagiCategoriesProvider);
return categoriesAsync.when(
data: (kagiCategories) => ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Refine Category', style: theme.textTheme.titleSmall),
FilterChip(
label: const Text('All'),
selected: session.currentCategory == null,
showCheckmark: false,
onSelected: (_) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(null);
await notifier.discover();
},
),
],
),
const SizedBox(height: 8),
for (final MapEntry(key: groupName, value: slugs)
in kagiCategories.groups.entries) ...[
_SectionHeader(title: groupName),
const SizedBox(height: 6),
_CategoryGrid(
categories: kagiCategories.categories,
slugs: slugs,
currentCategory: session.currentCategory,
onCategorySelected: (slug) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(
session.currentCategory == slug ? null : slug,
);
await notifier.discover();
},
),
const SizedBox(height: 10),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(
sourceKind: session.sourceKind,
mode: session.mode,
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(),
);
}
}
class _ModeContextPanel extends ConsumerWidget {
final ScrollController scrollController;
final SmallWebSessionState session;
final KagiSmallWebMode mode;
const _ModeContextPanel({
super.key,
required this.scrollController,
required this.session,
required this.mode,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final (IconData icon, String description) = switch (mode) {
KagiSmallWebMode.appreciated => (
Icons.volunteer_activism,
'Browse highly curated, user-appreciated links from the small web community.',
),
KagiSmallWebMode.videos => (
Icons.video_library,
'Discover video content from independent creators across the small web.',
),
KagiSmallWebMode.code => (
Icons.data_object,
'Find code snippets, repositories, and technical articles from personal sites.',
),
KagiSmallWebMode.comics => (
Icons.auto_stories,
'Explore indie comics and web-graphics from independent illustrators.',
),
KagiSmallWebMode.web => (Icons.language, ''),
};
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
const SizedBox(height: 16),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(icon, size: 36, color: colorScheme.onPrimaryContainer),
),
),
const SizedBox(height: 12),
Text(
'Searching ${mode.label}',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
description,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode),
],
);
}
}
class _DefaultContentPanel extends ConsumerWidget {
final ScrollController scrollController;
final SmallWebSessionState session;
const _DefaultContentPanel({
super.key,
required this.scrollController,
required this.session,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
),
const SizedBox(height: 12),
if (session.sourceKind == SmallWebSourceKind.wander) ...[
_WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () async {
Navigator.of(context).pop();
await showWanderConsoleSheet(context);
},
icon: const Icon(Icons.dns, size: 18),
label: const Text('Browse Consoles'),
),
const SizedBox(height: 12),
],
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode),
],
);
}
}
// --- Shared sub-widgets ---
class _DiscoverButton extends ConsumerWidget {
const _DiscoverButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(
smallWebSessionControllerProvider.select((s) => s.isLoading),
);
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading
? null
: () async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
if (context.mounted) Navigator.pop(context);
await notifier.discover();
},
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.explore, size: 20),
label: const Text('Discover'),
),
);
}
}
class _InfoMessageCard extends StatelessWidget {
final String message;
const _InfoMessageCard({required this.message});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSecondaryContainer,
),
),
),
],
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _CategoryGrid extends StatelessWidget {
final Map<String, KagiCategoryDefinition> categories;
final List<String> slugs;
final String? currentCategory;
final ValueChanged<String> onCategorySelected;
const _CategoryGrid({
required this.categories,
required this.slugs,
required this.currentCategory,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
final visibleSlugs = slugs.where((s) => categories.containsKey(s)).toList();
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3.5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: visibleSlugs.length,
itemBuilder: (context, index) {
final slug = visibleSlugs[index];
final cat = categories[slug]!;
return _CategoryTile(
label: cat.label,
emoji: cat.emoji,
isSelected: currentCategory == slug,
onTap: () => onCategorySelected(slug),
);
},
);
}
}
class _CategoryTile extends StatelessWidget {
final String label;
final String emoji;
final bool isSelected;
final VoidCallback onTap;
const _CategoryTile({
required this.label,
required this.emoji,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outlineVariant,
width: isSelected ? 1.5 : 1,
),
),
child: Row(
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
);
}
}
// --- Loading / Error states ---
class _SmallWebMenuLoading extends StatelessWidget {
final ScrollController scrollController;
const _SmallWebMenuLoading({required this.scrollController});
@override
Widget build(BuildContext context) {
return Skeletonizer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Mode chips skeleton
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Bone(
width: 60,
height: 32,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
SizedBox(width: 8),
Bone(
width: 70,
height: 32,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
SizedBox(width: 8),
Bone(
width: 65,
height: 32,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: const [
Bone.text(words: 2),
SizedBox(height: 8),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
],
),
),
],
),
);
}
}
class _SkeletonHistoryItem extends StatelessWidget {
const _SkeletonHistoryItem();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 3),
child: Padding(
padding: EdgeInsets.only(left: 12, top: 10, bottom: 10),
child: Row(
children: [
Bone.circle(size: 32),
SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Bone.text(words: 2),
SizedBox(height: 3),
Bone.text(words: 3, fontSize: 12),
],
),
),
SizedBox(width: 48),
],
),
),
);
}
}
class _SmallWebMenuError extends StatelessWidget {
final ScrollController scrollController;
final Object error;
final VoidCallback onRetry;
const _SmallWebMenuError({
required this.scrollController,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Small Web unavailable',
exception: error,
onRetry: onRetry,
),
),
],
);
}
}
// --- Wander console card ---
class _WanderConsoleCard extends ConsumerWidget {
final Uri? currentConsoleUrl;
const _WanderConsoleCard({required this.currentConsoleUrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
if (currentConsoleUrl == null) {
return Card(
color: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.dns_outlined,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'No console selected',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
final statsAsync = ref.watch(
wanderConsoleStatsProvider(currentConsoleUrl!),
);
return Card(
color: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.dns, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
currentConsoleUrl!.host,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
statsAsync.when(
data: (stats) => Text(
'${stats.linkedConsoles} linked consoles \u00b7 ${stats.pages} pages',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
],
),
),
);
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
class SmallWebModeChips extends ConsumerWidget {
final KagiSmallWebMode? currentMode;
final bool isLoading;
final ValueChanged<KagiSmallWebMode> onModeSelected;
const SmallWebModeChips({
super.key,
required this.currentMode,
required this.isLoading,
required this.onModeSelected,
});
static String _formatCount(int count) {
if (count >= 1000) {
final k = count / 1000;
return k == k.roundToDouble()
? '${k.round()}k'
: '${k.toStringAsFixed(1)}k';
}
return count.toString();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final countsAsync = ref.watch(smallWebAllModeItemCountsProvider);
final counts = countsAsync.value ?? {};
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: KagiSmallWebMode.values.length,
itemBuilder: (context, index) {
final mode = KagiSmallWebMode.values[index];
final isSelected = currentMode == mode;
final count = counts[mode];
return Padding(
padding: const EdgeInsets.only(right: 8, top: 4),
child: ChoiceChip(
avatar: Icon(mode.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(mode.label),
if (count != null && count > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.15)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
_formatCount(count),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
],
],
),
selected: isSelected,
showCheckmark: false,
onSelected: isLoading ? null : (_) => onModeSelected(mode),
),
);
},
),
);
}
}
@@ -0,0 +1,668 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_session_controller.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_menu_sheet.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/sliding_pill_toggle.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/form_validators.dart';
Future<void> showWanderConsoleSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) => const _WanderConsoleSheet(),
);
}
class _WanderConsoleSheet extends HookConsumerWidget {
const _WanderConsoleSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionAsync = ref.watch(smallWebSessionControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
final searchController = useTextEditingController();
final searchQuery = useListenableSelector(
searchController,
() => searchController.text.toLowerCase(),
);
final showAllConsoles = useState<bool?>(null);
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.85,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
height: 4,
width: 40,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: () async {
Navigator.of(context).pop();
await showSmallWebMenuSheet(context);
},
icon: const Icon(Icons.arrow_back),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
Text(
'Select Console',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
FilledButton.tonalIcon(
onPressed: sessionAsync.isLoading || sessionAsync.hasError
? null
: () async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
if (context.mounted) {
Navigator.of(context).pop();
}
await notifier.discover(forceNewConsole: true);
},
icon: const Icon(Icons.shuffle, size: 18),
label: const Text('Random'),
),
],
),
),
Expanded(
child: sessionAsync.when(
data: (session) {
final currentConsoleUrl = session.currentConsoleUrl;
final effectiveShowAllConsoles =
showAllConsoles.value ?? currentConsoleUrl == null;
return _WanderConsoleSheetContent(
session: session,
searchController: searchController,
searchQuery: searchQuery,
scrollController: scrollController,
showAllConsoles: effectiveShowAllConsoles,
onToggleAllConsoles: (value) {
showAllConsoles.value = value;
},
onAddConsole: () => _showAddConsoleDialog(context, ref),
);
},
loading: () => _WanderConsoleSheetLoading(
scrollController: scrollController,
),
error: (error, _) => _WanderConsoleSheetError(
scrollController: scrollController,
error: error,
onRetry: ref
.read(smallWebSessionControllerProvider.notifier)
.discover,
),
),
),
],
);
},
);
}
}
class _WanderConsoleSheetContent extends StatelessWidget {
final SmallWebSessionState session;
final TextEditingController searchController;
final String searchQuery;
final ScrollController scrollController;
final bool showAllConsoles;
final ValueChanged<bool> onToggleAllConsoles;
final VoidCallback onAddConsole;
const _WanderConsoleSheetContent({
required this.session,
required this.searchController,
required this.searchQuery,
required this.scrollController,
required this.showAllConsoles,
required this.onToggleAllConsoles,
required this.onAddConsole,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final currentConsoleUrl = session.currentConsoleUrl;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TextField(
controller: searchController,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Filter consoles...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: searchQuery.isNotEmpty
? IconButton(
onPressed: searchController.clear,
icon: const Icon(Icons.clear, size: 20),
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
),
),
),
if (currentConsoleUrl != null) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SlidingPillToggle(
selectedIndex: showAllConsoles ? 1 : 0,
labels: const ['Linked', 'All'],
onChanged: (index) => onToggleAllConsoles(index == 1),
),
),
],
const SizedBox(height: 8),
const Divider(height: 1),
Expanded(
child: Stack(
children: [
if (showAllConsoles)
_AllConsoleList(
searchQuery: searchQuery,
scrollController: scrollController,
selectedConsoleUrl: currentConsoleUrl,
isLoading: false,
)
else
currentConsoleUrl == null
? const Center(
child: Text('No console selected yet. Press Discover.'),
)
: _LinkedConsoleList(
consoleUrl: currentConsoleUrl,
searchQuery: searchQuery,
scrollController: scrollController,
selectedConsoleUrl: currentConsoleUrl,
isLoading: false,
),
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
onPressed: onAddConsole,
tooltip: 'Add console by URL',
child: const Icon(Icons.add),
),
),
],
),
),
],
);
}
}
class _WanderConsoleSheetLoading extends StatelessWidget {
final ScrollController scrollController;
const _WanderConsoleSheetLoading({required this.scrollController});
@override
Widget build(BuildContext context) {
return Skeletonizer(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: const [
TextField(
decoration: InputDecoration(hintText: 'Filter consoles...'),
),
SizedBox(height: 12),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
],
),
);
}
}
class _SkeletonConsoleTile extends StatelessWidget {
const _SkeletonConsoleTile();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 3),
child: Padding(
padding: EdgeInsets.only(left: 12, top: 10, bottom: 10, right: 12),
child: Row(
children: [
Bone.circle(size: 32),
SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Bone.text(words: 2),
SizedBox(height: 3),
Bone.text(words: 1, fontSize: 12),
],
),
),
],
),
),
);
}
}
class _WanderConsoleSheetError extends StatelessWidget {
final ScrollController scrollController;
final Object error;
final VoidCallback onRetry;
const _WanderConsoleSheetError({
required this.scrollController,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Could not load Small Web session',
exception: error,
onRetry: onRetry,
),
),
],
);
}
}
class _LinkedConsoleList extends ConsumerWidget {
final Uri consoleUrl;
final String searchQuery;
final ScrollController scrollController;
final Uri? selectedConsoleUrl;
final bool isLoading;
const _LinkedConsoleList({
required this.consoleUrl,
required this.searchQuery,
required this.scrollController,
required this.selectedConsoleUrl,
required this.isLoading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final neighborsAsync = ref.watch(
wanderNeighborConsolesProvider(consoleUrl),
);
return neighborsAsync.when(
data: (consoles) {
final filtered = searchQuery.isEmpty
? consoles
: consoles
.where(
(c) =>
c.url.host.toLowerCase().contains(searchQuery) ||
c.url.toString().toLowerCase().contains(searchQuery),
)
.toList();
if (filtered.isEmpty) {
return Center(
child: Text(
searchQuery.isEmpty
? 'No linked consoles found.'
: 'No consoles matching "$searchQuery".',
),
);
}
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: filtered.length,
itemBuilder: (context, index) {
final console = filtered[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(child: Text('Failed to load consoles.')),
);
}
}
class _AllConsoleList extends ConsumerWidget {
final String searchQuery;
final ScrollController scrollController;
final Uri? selectedConsoleUrl;
final bool isLoading;
const _AllConsoleList({
required this.searchQuery,
required this.scrollController,
required this.selectedConsoleUrl,
required this.isLoading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final consolesAsync = ref.watch(wanderAllConsolesProvider(searchQuery));
return consolesAsync.when(
data: (consoles) {
if (consoles.isEmpty) {
return Center(
child: Text(
searchQuery.isEmpty
? 'No consoles discovered yet.'
: 'No consoles matching "$searchQuery".',
),
);
}
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: consoles.length,
itemBuilder: (context, index) {
final console = consoles[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(child: Text('Failed to load consoles.')),
);
}
}
class _ConsoleListTile extends ConsumerWidget {
final Uri url;
final int pageCount;
final Uri? selectedConsoleUrl;
final bool isLoading;
const _ConsoleListTile({
required this.url,
required this.pageCount,
required this.selectedConsoleUrl,
required this.isLoading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final isSelected = selectedConsoleUrl == url;
const borderRadius = BorderRadius.all(Radius.circular(12));
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
decoration: BoxDecoration(
borderRadius: borderRadius,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: borderRadius,
onTap: isLoading
? null
: () {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.selectConsole(url);
if (context.mounted) {
Navigator.of(context).pop();
}
// await notifier.discover();
},
child: Padding(
padding: const EdgeInsets.only(
left: 12,
top: 10,
bottom: 10,
right: 12,
),
child: Row(
children: [
UrlIcon([url], iconSize: 32),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
url.host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected ? colorScheme.primary : null,
),
),
if (pageCount > 0) ...[
const SizedBox(height: 3),
Text(
'$pageCount pages',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: colorScheme.primary,
size: 20,
),
],
),
),
),
),
);
}
}
Future<void> _showAddConsoleDialog(BuildContext context, WidgetRef ref) {
return showDialog(
context: context,
builder: (context) => _AddConsoleDialog(ref: ref),
);
}
class _AddConsoleDialog extends HookWidget {
final WidgetRef ref;
const _AddConsoleDialog({required this.ref});
@override
Widget build(BuildContext context) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final textController = useTextEditingController();
final isLoading = useState(false);
final errorMessage = useState<String?>(null);
Future<void> submit() async {
errorMessage.value = null;
if (formKey.currentState?.validate() != true) return;
final url = parseValidatedUrl(
textController.text,
eagerParsing: true,
onlyHttpProtocol: true,
);
if (url == null) return;
isLoading.value = true;
try {
final service = ref.read(wanderSourceServiceProvider);
final consoleUrl = await service.addConsoleFromUrl(url);
if (!context.mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added console ${consoleUrl.host}')),
);
} on Exception catch (e) {
if (!context.mounted) return;
isLoading.value = false;
errorMessage.value = e.toString().replaceFirst('Exception: ', '');
}
}
return AlertDialog(
title: const Text('Add Console'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Enter the URL of a Wander console. '
'The URL can point to the site root or the /wander/ path.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextFormField(
decoration: const InputDecoration(
label: Text('URL'),
hintText: 'https://example.com/wander/',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
controller: textController,
keyboardType: TextInputType.url,
autofocus: true,
enabled: !isLoading.value,
validator: (value) => validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: true,
),
),
if (errorMessage.value != null) ...[
const SizedBox(height: 8),
Text(
errorMessage.value!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: isLoading.value ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: isLoading.value ? null : submit,
child: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Add'),
),
],
);
}
}