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);
}
}