prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/core/logger.dart';
class DatabaseRegistry {
DatabaseRegistry._();
static final instance = DatabaseRegistry._();
final _databases = <String, GeneratedDatabase>{};
void register(String name, GeneratedDatabase db) {
_databases[name] = db;
}
Future<void> closeAll() async {
for (final entry in _databases.entries) {
try {
await entry.value.close();
logger.i('${entry.key} database closed');
} catch (e, st) {
logger.e(
'Failed to close ${entry.key} database',
error: e,
stackTrace: st,
);
}
}
_databases.clear();
}
}
@@ -0,0 +1,239 @@
/*
* 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';
@immutable
class AppColors extends ThemeExtension<AppColors> {
const AppColors._({
required this.seedColor,
required this.privateTabPurple,
required this.privateTabBackground,
required this.privateTabForeground,
required this.privateSelectionOverlay,
required this.isolatedTabTeal,
required this.isolatedTabBackground,
required this.isolatedTabForeground,
required this.isolatedSelectionOverlay,
required this.torPurple,
required this.torActiveGreen,
required this.torBackgroundGrey,
required this.warningAmber,
required this.auraPurple,
required this.auraGold,
required this.auraShadow,
required this.auraShadowHighlight,
required this.auraTint,
required this.brandLink,
});
final Color seedColor;
final Color privateTabPurple;
final Color privateTabBackground;
final Color privateTabForeground;
final Color privateSelectionOverlay;
final Color isolatedTabTeal;
final Color isolatedTabBackground;
final Color isolatedTabForeground;
final Color isolatedSelectionOverlay;
final Color torPurple;
final Color torActiveGreen;
final Color torBackgroundGrey;
final Color warningAmber;
final Color auraPurple;
final Color auraGold;
final Color auraShadow;
final Color auraShadowHighlight;
final Color auraTint;
final Color brandLink;
/// Brand colors derived from the logo (constant across themes).
static const brandPurple = Color(0xFF9C83F8);
static const brandYellow = Color(0xFFFBDC6B);
static const brandGrey = Color(0xFFA7A7A7);
static const light = AppColors._(
seedColor: Color(0xFF167C80),
privateTabPurple: Color(0xFF8000D7),
privateTabBackground: Color(0xFFF3E5F5),
privateTabForeground: Color(0xFF4A0072),
privateSelectionOverlay: Color(0x648000D7),
isolatedTabTeal: Color(0xFF00897B),
isolatedTabBackground: Color(0xFFE0F2F1),
isolatedTabForeground: Color(0xFF004D40),
isolatedSelectionOverlay: Color(0x6400897B),
torPurple: Color(0xFF7D4698),
torActiveGreen: Color(0xFF68B030),
torBackgroundGrey: Color(0xFFECEFF1),
warningAmber: Color(0xFFFFA000),
// Aura: brand colors lightened toward white
auraPurple: Color(0xFFE0D6FC),
auraGold: Color(0xFFFDF1D6),
auraShadow: Color(0xFFEDEDF0),
auraShadowHighlight: Color(0xFFE2E2E7),
auraTint: Color(0xFFFFFFFF),
brandLink: Color(0xFF7A5AF0),
);
static const dark = AppColors._(
seedColor: Color(0xFF167C80),
privateTabPurple: Color(0xFF8000D7),
privateTabBackground: Color(0xFF25003E),
privateTabForeground: Color(0xFFFFFFFF),
privateSelectionOverlay: Color(0x648000D7),
isolatedTabTeal: Color(0xFF00897B),
isolatedTabBackground: Color(0xFF003D36),
isolatedTabForeground: Color(0xFFFFFFFF),
isolatedSelectionOverlay: Color(0x6400897B),
torPurple: Color(0xFF7D4698),
torActiveGreen: Color(0xFF68B030),
torBackgroundGrey: Color(0xFF333A41),
warningAmber: Color(0xFFFFA000),
// Aura: brand colors darkened toward black
auraPurple: Color(0xFF2C2543),
auraGold: Color(0xFF3C3827),
auraShadow: Color(0xFF222224),
auraShadowHighlight: Color(0xFF2D2D31),
auraTint: Color(0xFF000000),
brandLink: Color(0xFFFBDC6B),
);
@override
AppColors copyWith({
Color? seedColor,
Color? privateTabPurple,
Color? privateTabBackground,
Color? privateTabForeground,
Color? privateSelectionOverlay,
Color? isolatedTabTeal,
Color? isolatedTabBackground,
Color? isolatedTabForeground,
Color? isolatedSelectionOverlay,
Color? torPurple,
Color? torActiveGreen,
Color? torBackgroundGrey,
Color? warningAmber,
Color? auraPurple,
Color? auraGold,
Color? auraShadow,
Color? auraShadowHighlight,
Color? auraTint,
Color? brandLink,
}) {
return AppColors._(
seedColor: seedColor ?? this.seedColor,
privateTabPurple: privateTabPurple ?? this.privateTabPurple,
privateTabBackground: privateTabBackground ?? this.privateTabBackground,
privateTabForeground: privateTabForeground ?? this.privateTabForeground,
privateSelectionOverlay:
privateSelectionOverlay ?? this.privateSelectionOverlay,
isolatedTabTeal: isolatedTabTeal ?? this.isolatedTabTeal,
isolatedTabBackground:
isolatedTabBackground ?? this.isolatedTabBackground,
isolatedTabForeground:
isolatedTabForeground ?? this.isolatedTabForeground,
isolatedSelectionOverlay:
isolatedSelectionOverlay ?? this.isolatedSelectionOverlay,
torPurple: torPurple ?? this.torPurple,
torActiveGreen: torActiveGreen ?? this.torActiveGreen,
torBackgroundGrey: torBackgroundGrey ?? this.torBackgroundGrey,
warningAmber: warningAmber ?? this.warningAmber,
auraPurple: auraPurple ?? this.auraPurple,
auraGold: auraGold ?? this.auraGold,
auraShadow: auraShadow ?? this.auraShadow,
auraShadowHighlight: auraShadowHighlight ?? this.auraShadowHighlight,
auraTint: auraTint ?? this.auraTint,
brandLink: brandLink ?? this.brandLink,
);
}
@override
AppColors lerp(covariant ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) {
return this;
}
return AppColors._(
seedColor: Color.lerp(seedColor, other.seedColor, t)!,
privateTabPurple: Color.lerp(
privateTabPurple,
other.privateTabPurple,
t,
)!,
privateTabBackground: Color.lerp(
privateTabBackground,
other.privateTabBackground,
t,
)!,
privateTabForeground: Color.lerp(
privateTabForeground,
other.privateTabForeground,
t,
)!,
privateSelectionOverlay: Color.lerp(
privateSelectionOverlay,
other.privateSelectionOverlay,
t,
)!,
isolatedTabTeal: Color.lerp(isolatedTabTeal, other.isolatedTabTeal, t)!,
isolatedTabBackground: Color.lerp(
isolatedTabBackground,
other.isolatedTabBackground,
t,
)!,
isolatedTabForeground: Color.lerp(
isolatedTabForeground,
other.isolatedTabForeground,
t,
)!,
isolatedSelectionOverlay: Color.lerp(
isolatedSelectionOverlay,
other.isolatedSelectionOverlay,
t,
)!,
torPurple: Color.lerp(torPurple, other.torPurple, t)!,
torActiveGreen: Color.lerp(torActiveGreen, other.torActiveGreen, t)!,
torBackgroundGrey: Color.lerp(
torBackgroundGrey,
other.torBackgroundGrey,
t,
)!,
warningAmber: Color.lerp(warningAmber, other.warningAmber, t)!,
auraPurple: Color.lerp(auraPurple, other.auraPurple, t)!,
auraGold: Color.lerp(auraGold, other.auraGold, t)!,
auraShadow: Color.lerp(auraShadow, other.auraShadow, t)!,
auraShadowHighlight: Color.lerp(
auraShadowHighlight,
other.auraShadowHighlight,
t,
)!,
auraTint: Color.lerp(auraTint, other.auraTint, t)!,
brandLink: Color.lerp(brandLink, other.brandLink, t)!,
);
}
/// Get AppColors from the current theme
static AppColors of(BuildContext context) {
return Theme.of(context).extension<AppColors>() ??
switch (Theme.of(context).brightness) {
Brightness.dark => dark,
Brightness.light => light,
};
}
}
@@ -0,0 +1,34 @@
/*
* 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/riverpod.dart';
import 'package:weblibre/core/logger.dart';
final class ErrorObserver extends ProviderObserver {
const ErrorObserver();
@override
void providerDidFail(
ProviderObserverContext context,
Object error,
StackTrace stackTrace,
) {
logger.e('Provider ${context.provider} threw $error at $stackTrace');
}
}
+429
View File
@@ -0,0 +1,429 @@
/*
* 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 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/utils/filesystem.dart' as fs;
final filesystem = _Filesystem();
class _Filesystem {
late final Directory dataDir;
late final Directory profilesDir;
late final UuidValue selectedProfile;
late final Directory selectedProfileDir;
late final Directory profileDatabasesDir;
late final String relativeProfilePath;
Future<List<Directory>> getAvailableProfileDirectories() {
return profilesDir.list().transform(fs.profileTransformer).toList();
}
Future<Profile?> readProfileMetadata(Directory profileDir) {
return fs.readProfileMetadata(profileDir);
}
Directory getProfileDir(UuidValue uuid) {
return fs.getProfileDir(profilesDir, uuid);
}
Future<bool> createNewProfile(Profile profile) {
return fs.createNewProfile(profilesDir, profile);
}
Future<void> updateProfileMetadata(Profile profile) {
return fs.writeProfileMetadata(getProfileDir(profile.uuidValue), profile);
}
Future<void> setStartupProfile(UuidValue profile) {
return fs.writeStartupProfile(profilesDir, profile, flush: true);
}
Future<void> clearMozillaProfileCache(String profileId) {
return fs.clearMozillaProfileCache(selectedProfileDir, profileId);
}
List<String> getMozillaProfileIds(UuidValue uuid) {
return fs.getMozillaProfileIds(getProfileDir(uuid));
}
/// If the old canonical location `{profileDir}/mozilla/` exists as a real
/// directory and the new location `{profileDir}/files/mozilla/` does not,
/// rename the former to the latter.
/// Returns `true` if a migration was performed.
static Future<bool> _migrateMozillaDirToFiles(Directory profileDir) async {
final oldDir = Directory(p.join(profileDir.path, 'mozilla'));
final newDir = Directory(p.join(profileDir.path, 'files', 'mozilla'));
final oldType = await FileSystemEntity.type(
oldDir.path,
followLinks: false,
);
if (oldType == FileSystemEntityType.directory && !await newDir.exists()) {
await Directory(p.join(profileDir.path, 'files')).create(recursive: true);
await oldDir.rename(newDir.path);
return true;
}
return false;
}
Future<void> _migrateGeckoCache(Directory profileDir) async {
final profileIds = fs.getMozillaProfileIds(profileDir);
final globalCacheDir = Directory(p.join(dataDir.path, 'cache'));
for (final profileId in profileIds) {
final oldCache = Directory(p.join(globalCacheDir.path, profileId));
final newCache = Directory(p.join(profileDir.path, 'cache', profileId));
if (await oldCache.exists() && !await newCache.exists()) {
try {
await oldCache.rename(newCache.path);
} catch (e, s) {
logger.w(
'Failed to migrate Gecko cache for $profileId',
error: e,
stackTrace: s,
);
}
}
}
}
/// Ensure the top-level `{filesDir}/mozilla` symlink points to the given
/// profile's `files/mozilla/` directory. Old versions created this symlink
/// targeting `{profile}/mozilla` which no longer exists after the migration
/// moved it to `{profile}/files/mozilla`. GeckoView's `extensions.json`
/// stores absolute paths through this symlink, so it must stay valid.
static Future<void> _linkMozillaDir(
Directory filesDir,
Directory profileDir,
) async {
final mozillaDir = Directory(p.join(profileDir.path, 'files', 'mozilla'));
await mozillaDir.create(recursive: true);
final mozillaPath = p.join(filesDir.path, 'mozilla');
final currentType = await FileSystemEntity.type(
mozillaPath,
followLinks: false,
);
switch (currentType) {
case FileSystemEntityType.notFound:
break;
case FileSystemEntityType.link:
final link = Link(mozillaPath);
try {
if (await link.target() == mozillaDir.path) {
return;
}
} on FileSystemException {
// Replace unreadable or broken links.
}
await link.delete();
default:
// Move aside any non-link entity (directory, file, etc.)
final backupPath = p.join(
filesDir.path,
'mozilla.backup.${DateTime.now().millisecondsSinceEpoch}',
);
if (currentType == FileSystemEntityType.directory) {
await Directory(mozillaPath).rename(backupPath);
} else {
await File(mozillaPath).rename(backupPath);
}
}
await Link(mozillaPath).create(mozillaDir.path);
}
/// Remove path-sensitive Gecko caches that may contain stale absolute paths.
/// Gecko regenerates these on next startup.
static Future<void> _healGeckoStartupCaches(Directory profileDir) async {
final profileIds = fs.getMozillaProfileIds(profileDir);
for (final profileId in profileIds) {
final mozProfileDir = Directory(
p.join(profileDir.path, 'files', 'mozilla', profileId),
);
if (!await mozProfileDir.exists()) continue;
// addonStartup.json.lz4 caches absolute addon paths
final addonStartup = File(
p.join(mozProfileDir.path, 'addonStartup.json.lz4'),
);
if (await addonStartup.exists()) {
try {
await addonStartup.delete();
logger.i('Cleared addonStartup.json.lz4 for $profileId');
} catch (e, s) {
logger.w(
'Failed to clear addonStartup.json.lz4 for $profileId',
error: e,
stackTrace: s,
);
}
}
// startupCache may contain stale path references
final startupCache = Directory(
p.join(mozProfileDir.path, 'startupCache'),
);
if (await startupCache.exists()) {
try {
await startupCache.delete(recursive: true);
logger.i('Cleared startupCache for $profileId');
} catch (e, s) {
logger.w(
'Failed to clear startupCache for $profileId',
error: e,
stackTrace: s,
);
}
}
}
}
/// Rewrite absolute paths in `extensions.json` that still reference the
/// old pre-migration layout (`{filesDir}/mozilla/…`). After migration the
/// real location is `{filesDir}/weblibre_profiles/profile-…/files/mozilla/…`
/// and the top-level symlink bridges the two, but fixing the stored paths
/// removes the permanent dependency on the symlink.
/// Returns `true` if any paths were rewritten.
static Future<bool> _migrateExtensionPaths(
Directory filesDir,
Directory profileDir,
) async {
final profileIds = fs.getMozillaProfileIds(profileDir);
// Pattern: /data/user/0/eu.weblibre.gecko/files/mozilla/
// Should become: /data/user/0/eu.weblibre.gecko/files/weblibre_profiles/profile-…/files/mozilla/
final oldPrefix = '${filesDir.path}/mozilla/';
final newPrefix = '${profileDir.path}/files/mozilla/';
if (oldPrefix == newPrefix) return false;
var migrated = false;
for (final profileId in profileIds) {
final extensionsFile = File(
p.join(
profileDir.path,
'files',
'mozilla',
profileId,
'extensions.json',
),
);
if (!await extensionsFile.exists()) continue;
try {
final content = await extensionsFile.readAsString();
if (!content.contains(oldPrefix)) continue;
final json = jsonDecode(content);
var changed = false;
if (json is Map<String, dynamic> && json['addons'] is List) {
for (final addon in json['addons'] as List) {
if (addon is! Map<String, dynamic>) continue;
final path = addon['path'];
if (path is String && path.startsWith(oldPrefix)) {
addon['path'] = path.replaceFirst(oldPrefix, newPrefix);
changed = true;
}
final rootURI = addon['rootURI'];
if (rootURI is String && rootURI.contains(oldPrefix)) {
addon['rootURI'] = rootURI.replaceFirst(oldPrefix, newPrefix);
changed = true;
}
}
}
if (changed) {
await extensionsFile.writeAsString(jsonEncode(json), flush: true);
logger.i('Migrated extension paths in $profileId/extensions.json');
migrated = true;
}
} catch (e, s) {
logger.w(
'Failed to migrate extension paths for $profileId',
error: e,
stackTrace: s,
);
}
}
return migrated;
}
/// Run all post-migration healing for a profile directory.
/// Called during init for the active profile and after backup restore.
Future<void> healProfile(Directory profileDir) async {
final filesDir = profilesDir.parent;
final dirMigrated = await _migrateMozillaDirToFiles(profileDir);
await _migrateGeckoCache(profileDir);
final pathsMigrated = await _migrateExtensionPaths(filesDir, profileDir);
// Only update the global symlink for the currently active profile —
// restoring a non-active profile must not repoint it.
final isActiveProfile = profileDir.path == selectedProfileDir.path;
if (isActiveProfile) {
await _linkMozillaDir(filesDir, profileDir);
}
// Only clear Gecko startup caches when a path migration actually happened,
// to avoid a recurring startup performance penalty.
if (dirMigrated || pathsMigrated) {
await _healGeckoStartupCaches(profileDir);
}
}
Future<void> _setupSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sandboxing.
final cachebase = (await path_provider.getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cachebase;
}
Future<void> _copyDirectory(
Directory source,
Directory destination,
bool Function(FileSystemEntity e) filter,
) async {
// Create destination directory
await destination.create(recursive: true);
// List all contents
await for (final entity in source.list().where(filter)) {
final newPath = p.join(destination.path, p.basename(entity.path));
if (entity is Directory) {
// Recursively copy subdirectory
await _copyDirectory(entity, Directory(newPath), filter);
} else if (entity is File) {
// Copy file
await entity.copy(newPath);
} else if (entity is Link) {
// Copy link
await Link(newPath).create(await entity.target());
}
}
}
Future<void> init() async {
final filesDir = await path_provider.getApplicationSupportDirectory();
dataDir = filesDir.parent;
profilesDir = Directory(p.join(filesDir.path, fs.profilesDirName));
await profilesDir.create();
final selectedProfile = await fs.selectStartupProfile(profilesDir);
if (selectedProfile == null) {
final defaultProfile = Profile.create(name: 'Default');
if (!await fs.createNewProfile(profilesDir, defaultProfile)) {
throw Exception('Unable to create default profile');
}
this.selectedProfile = defaultProfile.uuidValue;
await fs.writeStartupProfile(profilesDir, defaultProfile.uuidValue);
final mozillaDir = Directory(p.join(filesDir.path, 'mozilla'));
if (await mozillaDir.exists()) {
final type = await FileSystemEntity.type(mozillaDir.path);
if (type != FileSystemEntityType.link) {
await _migrate(defaultProfile, mozillaDir, filesDir);
}
}
} else {
this.selectedProfile = selectedProfile;
}
relativeProfilePath = p.join(
fs.profilesDirName,
'${fs.profileDirPrefix}${this.selectedProfile.uuid}',
);
selectedProfileDir = Directory(p.join(filesDir.path, relativeProfilePath));
await selectedProfileDir.create();
profileDatabasesDir = Directory(
p.join(selectedProfileDir.path, 'databases'),
);
await profileDatabasesDir.create();
await healProfile(selectedProfileDir);
await _setupSqliteCache();
}
Future<void> _migrate(
Profile defaultProfile,
Directory mozillaDir,
Directory filesDir,
) async {
final profileDir = getProfileDir(defaultProfile.uuidValue);
final newMozillaDir = Directory(
p.join(profileDir.path, 'files', 'mozilla'),
);
await newMozillaDir.create(recursive: true);
await mozillaDir.rename(newMozillaDir.path);
await _copyDirectory(
filesDir,
Directory(p.join(profileDir.path, 'files')),
(e) => e is! Directory || p.basename(e.path) != fs.profilesDirName,
);
final profileDatabasesDir = Directory(p.join(profileDir.path, 'databases'));
await _copyDirectory(
Directory(p.join(dataDir.path, 'databases')),
profileDatabasesDir,
(e) => true,
);
final dbFolder = await path_provider.getApplicationDocumentsDirectory();
final bangDb = File(p.join(dbFolder.path, 'bang3.db'));
await bangDb.copy(p.join(profileDatabasesDir.path, 'bang.db'));
final feedDb = File(p.join(dbFolder.path, 'feed.db'));
await feedDb.copy(p.join(profileDatabasesDir.path, 'feed.db'));
final tabDb = File(p.join(dbFolder.path, 'tab2.db'));
await tabDb.copy(p.join(profileDatabasesDir.path, 'tab.db'));
final userDb = File(p.join(dbFolder.path, 'user.db'));
await userDb.copy(p.join(profileDatabasesDir.path, 'user.db'));
}
}
@@ -0,0 +1,45 @@
/*
* 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:io';
import 'package:exceptions/exceptions.dart';
import 'package:http/http.dart';
ErrorMessage handleHttpError(Exception exception, StackTrace stackTrace) {
return switch (exception) {
SocketException() => const ErrorMessage(
source: 'http',
message: 'Could not contact remote service',
),
HttpException() => const ErrorMessage(
source: 'http',
message: 'Web request returned error',
),
FormatException() => const ErrorMessage(
source: 'http',
message: 'Bad response format',
),
ClientException() => const ErrorMessage(
source: 'http',
message: 'Could not contact remote service',
),
_ => ErrorMessage.fromException(exception, stackTrace),
};
}
+38
View File
@@ -0,0 +1,38 @@
/*
* 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:logger/logger.dart';
class _DebugLogFilter extends LogFilter {
@override
bool shouldLog(LogEvent event) {
//Log all events
return true;
}
}
final loggerMemory = MemoryOutput(
bufferSize: 255,
secondOutput: ConsoleOutput(),
);
final logger = Logger(
filter: _DebugLogFilter(),
printer: PrettyPrinter(dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart),
output: loggerMemory,
);
@@ -0,0 +1,35 @@
/*
* 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/widgets.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app_state.g.dart';
@Riverpod(keepAlive: true)
class AppStateKey extends _$AppStateKey {
void reset() {
state = GlobalKey(debugLabel: 'RootKey');
}
@override
GlobalKey build() {
return GlobalKey(debugLabel: 'RootKey');
}
}
@@ -0,0 +1,73 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AppStateKey)
final appStateKeyProvider = AppStateKeyProvider._();
final class AppStateKeyProvider
extends $NotifierProvider<AppStateKey, GlobalKey<State<StatefulWidget>>> {
AppStateKeyProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appStateKeyProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appStateKeyHash();
@$internal
@override
AppStateKey create() => AppStateKey();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GlobalKey<State<StatefulWidget>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GlobalKey<State<StatefulWidget>>>(
value,
),
);
}
}
String _$appStateKeyHash() => r'113c208139d84cdd52f623c2c6298b430fc1a889';
abstract class _$AppStateKey
extends $Notifier<GlobalKey<State<StatefulWidget>>> {
GlobalKey<State<StatefulWidget>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
GlobalKey<State<StatefulWidget>>,
GlobalKey<State<StatefulWidget>>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
GlobalKey<State<StatefulWidget>>,
GlobalKey<State<StatefulWidget>>
>,
GlobalKey<State<StatefulWidget>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,33 @@
/*
* 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:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/design/app_colors.dart';
part 'defaults.g.dart';
@Riverpod(keepAlive: true)
Color lightSeedColorFallback(Ref ref) => AppColors.light.seedColor;
@Riverpod(keepAlive: true)
Color darkSeedColorFallback(Ref ref) => AppColors.dark.seedColor;
@Riverpod(keepAlive: true)
Uri docsUri(Ref ref) => Uri.parse('https://docs.weblibre.eu/');
@@ -0,0 +1,134 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'defaults.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(lightSeedColorFallback)
final lightSeedColorFallbackProvider = LightSeedColorFallbackProvider._();
final class LightSeedColorFallbackProvider
extends $FunctionalProvider<Color, Color, Color>
with $Provider<Color> {
LightSeedColorFallbackProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'lightSeedColorFallbackProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$lightSeedColorFallbackHash();
@$internal
@override
$ProviderElement<Color> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Color create(Ref ref) {
return lightSeedColorFallback(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Color value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Color>(value),
);
}
}
String _$lightSeedColorFallbackHash() =>
r'851efdcb11e4367ea2e54f1884a73fd4cd841d4a';
@ProviderFor(darkSeedColorFallback)
final darkSeedColorFallbackProvider = DarkSeedColorFallbackProvider._();
final class DarkSeedColorFallbackProvider
extends $FunctionalProvider<Color, Color, Color>
with $Provider<Color> {
DarkSeedColorFallbackProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'darkSeedColorFallbackProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$darkSeedColorFallbackHash();
@$internal
@override
$ProviderElement<Color> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Color create(Ref ref) {
return darkSeedColorFallback(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Color value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Color>(value),
);
}
}
String _$darkSeedColorFallbackHash() =>
r'161a8c4318108c31d5c300441bc3332d08c24496';
@ProviderFor(docsUri)
final docsUriProvider = DocsUriProvider._();
final class DocsUriProvider extends $FunctionalProvider<Uri, Uri, Uri>
with $Provider<Uri> {
DocsUriProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'docsUriProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$docsUriHash();
@$internal
@override
$ProviderElement<Uri> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Uri create(Ref ref) {
return docsUri(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Uri value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Uri>(value),
);
}
}
String _$docsUriHash() => r'6456efcf97ddc7ee87a67e2d3380f7241e3d76ea';
@@ -0,0 +1,44 @@
/*
* 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:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'device_info.g.dart';
@Riverpod(keepAlive: true)
class AndroidDeviceInfo extends _$AndroidDeviceInfo {
@override
Future<AndroidDeviceInfoData?> build() async {
if (!Platform.isAndroid) return null;
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
return AndroidDeviceInfoData(sdkInt: androidInfo.version.sdkInt);
}
}
class AndroidDeviceInfoData {
final int sdkInt;
const AndroidDeviceInfoData({required this.sdkInt});
}
@@ -0,0 +1,60 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_info.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AndroidDeviceInfo)
final androidDeviceInfoProvider = AndroidDeviceInfoProvider._();
final class AndroidDeviceInfoProvider
extends $AsyncNotifierProvider<AndroidDeviceInfo, AndroidDeviceInfoData?> {
AndroidDeviceInfoProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'androidDeviceInfoProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$androidDeviceInfoHash();
@$internal
@override
AndroidDeviceInfo create() => AndroidDeviceInfo();
}
String _$androidDeviceInfoHash() => r'05c6cc63a6ee34f137aef538d65e8ab94b9cca89';
abstract class _$AndroidDeviceInfo
extends $AsyncNotifier<AndroidDeviceInfoData?> {
FutureOr<AndroidDeviceInfoData?> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<AndroidDeviceInfoData?>, AndroidDeviceInfoData?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<AndroidDeviceInfoData?>,
AndroidDeviceInfoData?
>,
AsyncValue<AndroidDeviceInfoData?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,42 @@
/*
* 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:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'format.g.dart';
@Riverpod(keepAlive: true)
class Format extends _$Format {
String fullDateTime(DateTime date) {
final pattern = DateFormat('yMMMMd').addPattern('Hm');
return pattern.format(date);
}
String shortDate(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date);
}
@override
Future<void> build() async {
await initializeDateFormatting();
}
}
@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'format.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(Format)
final formatProvider = FormatProvider._();
final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
FormatProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'formatProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$formatHash();
@$internal
@override
Format create() => Format();
}
String _$formatHash() => r'695b94cc4b25596e4ef953d08ba954424a5ec33e';
abstract class _$Format extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/data/models/drag_data.dart';
part 'global_drop.g.dart';
@Riverpod()
class WillAcceptDrop extends _$WillAcceptDrop {
// ignore: use_setters_to_change_properties api decision
void setData(DropTargetData data) {
state = data;
}
void clear() {
state = null;
}
@override
DropTargetData? build() {
return null;
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'global_drop.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(WillAcceptDrop)
final willAcceptDropProvider = WillAcceptDropProvider._();
final class WillAcceptDropProvider
extends $NotifierProvider<WillAcceptDrop, DropTargetData?> {
WillAcceptDropProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'willAcceptDropProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$willAcceptDropHash();
@$internal
@override
WillAcceptDrop create() => WillAcceptDrop();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(DropTargetData? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<DropTargetData?>(value),
);
}
}
String _$willAcceptDropHash() => r'97b47784ef9101757602b4408d1f545ef2308830';
abstract class _$WillAcceptDrop extends $Notifier<DropTargetData?> {
DropTargetData? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<DropTargetData?, DropTargetData?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<DropTargetData?, DropTargetData?>,
DropTargetData?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/user/data/providers.dart';
part 'persisted_bool.g.dart';
enum PersistedBoolKey {
extensionsExpanded(key: 'ExtensionsExpanded', defaultValue: false),
searchSuggestionsExpanded(
key: 'SearchSuggestionsExpanded',
defaultValue: true,
),
tabSuggestions(key: 'TabSuggestions', defaultValue: false);
const PersistedBoolKey({required this.key, required this.defaultValue});
final String key;
final bool defaultValue;
}
@Riverpod(keepAlive: true)
class PersistedBool extends _$PersistedBool {
void toggle() => state = !state;
// ignore: use_setters_to_change_properties
void set(bool value) => state = value;
@override
bool build(PersistedBoolKey key) {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: key.key,
encode: (state) => jsonEncode([state]),
decode: (encoded) => (jsonDecode(encoded) as List<dynamic>).first as bool,
);
return stateOrNull ?? key.defaultValue;
}
}
@@ -0,0 +1,107 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'persisted_bool.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(PersistedBool)
final persistedBoolProvider = PersistedBoolFamily._();
final class PersistedBoolProvider
extends $NotifierProvider<PersistedBool, bool> {
PersistedBoolProvider._({
required PersistedBoolFamily super.from,
required PersistedBoolKey super.argument,
}) : super(
retry: null,
name: r'persistedBoolProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$persistedBoolHash();
@override
String toString() {
return r'persistedBoolProvider'
''
'($argument)';
}
@$internal
@override
PersistedBool create() => PersistedBool();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
@override
bool operator ==(Object other) {
return other is PersistedBoolProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$persistedBoolHash() => r'97fd7ed82dfde444fddf4475a3982be00f5f197a';
final class PersistedBoolFamily extends $Family
with
$ClassFamilyOverride<
PersistedBool,
bool,
bool,
bool,
PersistedBoolKey
> {
PersistedBoolFamily._()
: super(
retry: null,
name: r'persistedBoolProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
PersistedBoolProvider call(PersistedBoolKey key) =>
PersistedBoolProvider._(argument: key, from: this);
@override
String toString() => r'persistedBoolProvider';
}
abstract class _$PersistedBool extends $Notifier<bool> {
late final _$args = ref.$arg as PersistedBoolKey;
PersistedBoolKey get key => _$args;
bool build(PersistedBoolKey key);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -0,0 +1,113 @@
/*
* 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 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/providers/app_state.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
import 'package:weblibre/features/user/domain/repositories/onboarding.dart';
part 'router.g.dart';
@Riverpod(keepAlive: true)
Future<GoRouter> router(Ref ref) async {
ref.watch(appStateKeyProvider); //Rebuild router on key changes
final onboardingRepository = ref.read(onboardingRepositoryProvider.notifier);
unawaited(ref.read(profileAuthStateProvider.notifier).bootstrapFromProfile());
String? initialLocation;
final onboardingMandatory = await onboardingRepository.isOutdated();
if (onboardingMandatory) {
final current = await onboardingRepository.getCurrentRevision();
final route = OnboardingRoute(
currentRevision: current ?? -1,
targetRevision: OnboardingRepository.targetRevision,
);
initialLocation = route.location;
}
final profileAuthRefreshListenable = ref.watch(profileAuthProvider);
return GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
initialLocation: initialLocation ?? const LockRoute().location,
refreshListenable: profileAuthRefreshListenable,
redirect: (context, state) {
final authenticated = ref.read(profileAuthStateProvider);
final currentTopRouteName = state.topRoute?.name;
final isOnLockRoute = currentTopRouteName == LockRoute.name;
final isOnOnboarding = currentTopRouteName == OnboardingRoute.name;
// Don't redirect during onboarding
if (isOnOnboarding) return null;
if (!authenticated && !isOnLockRoute) {
return const LockRoute().location;
}
if (authenticated && isOnLockRoute) {
return const BrowserRoute().location;
}
return null;
},
);
}
@Riverpod(keepAlive: true)
class CurrentTopRoute extends _$CurrentTopRoute {
@override
RouteBase? build() {
final router = ref.watch(routerProvider).value;
if (router == null) return null;
GoRoute? getCurrentRoute() {
final config = router.routerDelegate.currentConfiguration;
if (config.isEmpty) {
return null;
}
final match = config.last;
return match.route;
}
void update() {
unawaited(
Future(() {
state = getCurrentRoute();
}),
);
}
router.routerDelegate.addListener(update);
ref.onDispose(() => router.routerDelegate.removeListener(update));
return getCurrentRoute();
}
}
@@ -0,0 +1,96 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'router.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(router)
final routerProvider = RouterProvider._();
final class RouterProvider
extends
$FunctionalProvider<AsyncValue<GoRouter>, GoRouter, FutureOr<GoRouter>>
with $FutureModifier<GoRouter>, $FutureProvider<GoRouter> {
RouterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'routerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$routerHash();
@$internal
@override
$FutureProviderElement<GoRouter> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<GoRouter> create(Ref ref) {
return router(ref);
}
}
String _$routerHash() => r'4402ca2d7061945c395f3963d6bde29be8999f96';
@ProviderFor(CurrentTopRoute)
final currentTopRouteProvider = CurrentTopRouteProvider._();
final class CurrentTopRouteProvider
extends $NotifierProvider<CurrentTopRoute, RouteBase?> {
CurrentTopRouteProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentTopRouteProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentTopRouteHash();
@$internal
@override
CurrentTopRoute create() => CurrentTopRoute();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(RouteBase? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<RouteBase?>(value),
);
}
}
String _$currentTopRouteHash() => r'bf9956935ede399b815348926bb75c6bad22619e';
abstract class _$CurrentTopRoute extends $Notifier<RouteBase?> {
RouteBase? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<RouteBase?, RouteBase?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<RouteBase?, RouteBase?>,
RouteBase?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,152 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<BangMenuRoute>(
name: 'BangRoute',
path: '/bangs',
routes: [
TypedGoRoute<UserBangsRoute>(
name: 'UserBangsRoute',
path: 'user',
routes: [
TypedGoRoute<NewUserBangRoute>(name: 'NewUserBangRoute', path: 'new'),
TypedGoRoute<EditUserBangRoute>(
name: 'EditUserBangRoute',
path: 'edit',
),
],
),
TypedGoRoute<BangSearchRoute>(
name: 'BangSearchRoute',
path: 'search/:searchText',
),
TypedGoRoute<BangCategoriesRoute>(
name: 'BangCategoriesRoute',
path: 'categories',
routes: [
TypedGoRoute<BangCategoryRoute>(
name: 'BangCategoryRoute',
path: 'category/:category',
routes: [
TypedGoRoute<BangSubCategoryRoute>(
name: 'BangSubCategoryRoute',
path: ':subCategory',
),
],
),
],
),
],
)
class BangMenuRoute extends GoRouteData with $BangMenuRoute {
const BangMenuRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const BangMenuScreen();
}
}
class BangCategoriesRoute extends GoRouteData with $BangCategoriesRoute {
const BangCategoriesRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const BangCategoriesScreen();
}
}
class BangCategoryRoute extends GoRouteData with $BangCategoryRoute {
final String category;
const BangCategoryRoute({required this.category});
@override
Widget build(BuildContext context, GoRouterState state) {
return BangCategoryScreen(category: category);
}
}
class BangSubCategoryRoute extends GoRouteData with $BangSubCategoryRoute {
final String category;
final String subCategory;
const BangSubCategoryRoute({
required this.category,
required this.subCategory,
});
@override
Widget build(BuildContext context, GoRouterState state) {
return BangCategoryScreen(category: category, subCategory: subCategory);
}
}
class BangSearchRoute extends GoRouteData with $BangSearchRoute {
static const String emptySearchText = ' ';
//This should be nullable but isnt allowed by go_router
final String searchText;
const BangSearchRoute({this.searchText = BangSearchRoute.emptySearchText});
@override
Widget build(BuildContext context, GoRouterState state) {
return BangSearchScreen(
initialSearchText: (searchText.isEmpty || searchText == emptySearchText)
? null
: searchText,
);
}
}
class UserBangsRoute extends GoRouteData with $UserBangsRoute {
const UserBangsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const UserBangs();
}
}
class NewUserBangRoute extends GoRouteData with $NewUserBangRoute {
const NewUserBangRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const EditBangScreen(initialBang: null);
}
}
class EditUserBangRoute extends GoRouteData with $EditUserBangRoute {
final String initialBang;
const EditUserBangRoute({required this.initialBang});
@override
Widget build(BuildContext context, GoRouterState state) {
return EditBangScreen(
initialBang: Bang.fromJson(
jsonDecode(initialBang) as Map<String, dynamic>,
),
);
}
}
@@ -0,0 +1,121 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<BookmarksRoute>(
name: 'BookmarksRoute',
path: '/bookmarks',
routes: [
TypedGoRoute<BookmarkListRoute>(
name: 'BookmarkListRoute',
path: 'list/:entryGuid',
),
TypedGoRoute<BookmarkFolderAddRoute>(
name: 'BookmarkFolderAddRoute',
path: 'createFolder',
),
TypedGoRoute<BookmarkFolderEditRoute>(
name: 'BookmarkFolderEditRoute',
path: 'editFolder',
),
TypedGoRoute<BookmarkEntryAddRoute>(
name: 'BookmarkEntryAddRoute',
path: 'createEntry',
),
TypedGoRoute<BookmarkEntryEditRoute>(
name: 'BookmarkEntryEditRoute',
path: 'editEntry',
),
],
)
class BookmarksRoute extends GoRouteData with $BookmarksRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SizedBox.shrink();
}
}
class BookmarkListRoute extends GoRouteData with $BookmarkListRoute {
final String entryGuid;
const BookmarkListRoute({required this.entryGuid});
@override
Widget build(BuildContext context, GoRouterState state) {
return BookmarkListScreen(entryGuid: entryGuid);
}
}
class BookmarkFolderAddRoute extends GoRouteData with $BookmarkFolderAddRoute {
final String? parentGuid;
const BookmarkFolderAddRoute({required this.parentGuid});
@override
Widget build(BuildContext context, GoRouterState state) {
return BookmarkFolderEditScreen(parentGuid: parentGuid, folder: null);
}
}
class BookmarkFolderEditRoute extends GoRouteData
with $BookmarkFolderEditRoute {
final String folder;
const BookmarkFolderEditRoute({required this.folder});
@override
Widget build(BuildContext context, GoRouterState state) {
return BookmarkFolderEditScreen(
folder: BookmarkFolder.fromJson(
jsonDecode(folder) as Map<String, dynamic>,
),
);
}
}
class BookmarkEntryAddRoute extends GoRouteData with $BookmarkEntryAddRoute {
final String bookmarkInfo;
const BookmarkEntryAddRoute({required this.bookmarkInfo});
@override
Widget build(BuildContext context, GoRouterState state) {
return BookmarkEntryEditScreen(
initialInfo: BookmarkInfo.decode(jsonDecode(bookmarkInfo) as Object),
exisitingEntry: null,
);
}
}
class BookmarkEntryEditRoute extends GoRouteData with $BookmarkEntryEditRoute {
final String bookmarkEntry;
const BookmarkEntryEditRoute({required this.bookmarkEntry});
@override
Widget build(BuildContext context, GoRouterState state) {
return BookmarkEntryEditScreen(
initialInfo: null,
exisitingEntry: BookmarkEntry.fromJson(
jsonDecode(bookmarkEntry) as Map<String, dynamic>,
),
);
}
}
@@ -0,0 +1,282 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<BrowserRoute>(
name: BrowserRoute.name,
path: '/browser',
routes: [
TypedGoRoute<SearchRoute>(
name: 'SearchRoute',
path: 'search/:tabType/:searchText',
),
TypedGoRoute<TabViewRoute>(name: 'TabViewRoute', path: 'tab_view'),
TypedGoRoute<ContextMenuRoute>(
name: 'ContextMenuRoute',
path: 'context_menu',
),
TypedGoRoute<ContainerDraftRoute>(
name: 'ContainerDraftRoute',
path: 'container_draft',
),
TypedGoRoute<ContainerListRoute>(
name: 'ContainerListRoute',
path: 'containers',
routes: [
TypedGoRoute<ContainerCreateRoute>(
name: 'ContainerCreateRoute',
path: 'create/:containerData',
),
TypedGoRoute<ContainerEditRoute>(
name: 'ContainerEditRoute',
path: 'edit/:containerData',
),
],
),
TypedGoRoute<ContainerSelectionRoute>(
name: 'ContainerSelectionRoute',
path: 'select_container',
),
TypedGoRoute<TabTreeRoute>(
name: 'TabTreeRoute',
path: 'tab_tree/:rootTabId',
),
TypedGoRoute<OpenSharedContentRoute>(
name: 'OpenSharedContentRoute',
path: 'open_content',
),
TypedGoRoute<SelectProfileRoute>(
name: 'SelectProfileRoute',
path: 'profile',
),
],
)
class BrowserRoute extends GoRouteData with $BrowserRoute {
static const name = 'BrowserRoute';
const BrowserRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const BrowserScreen();
}
}
enum TabType { regular, private, child, isolated }
class SearchRoute extends GoRouteData with $SearchRoute {
static const String emptySearchText = ' ';
final TabType tabType;
//This should be nullable but isnt allowed by go_router
final String searchText;
final bool launchedFromIntent;
/// When provided, the search screen will load URLs into this existing tab
/// instead of creating a new tab. This also changes the UI to show
/// site-specific bangs instead of the tab type selector.
final String? tabId;
const SearchRoute({
required this.tabType,
this.searchText = SearchRoute.emptySearchText,
this.launchedFromIntent = false,
this.tabId,
});
@override
Widget build(BuildContext context, GoRouterState state) {
return SearchScreen(
tabType: tabType,
initialSearchText: (searchText.isEmpty || searchText == emptySearchText)
? null
: searchText,
launchedFromIntent: launchedFromIntent,
tabId: tabId,
);
}
}
bool _isContainerUiEnabled(BuildContext context) {
final settings = ProviderScope.containerOf(
context,
listen: false,
).read(generalSettingsWithDefaultsProvider);
return settings.showContainerUi;
}
class ContainerDraftRoute extends GoRouteData with $ContainerDraftRoute {
const ContainerDraftRoute();
@override
String? redirect(BuildContext context, GoRouterState state) {
return _isContainerUiEnabled(context)
? null
: const BrowserRoute().location;
}
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerDraftSuggestionsScreen();
}
}
class ContainerListRoute extends GoRouteData with $ContainerListRoute {
const ContainerListRoute();
@override
String? redirect(BuildContext context, GoRouterState state) {
return _isContainerUiEnabled(context)
? null
: const BrowserRoute().location;
}
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerListScreen();
}
}
class ContainerSelectionRoute extends GoRouteData
with $ContainerSelectionRoute {
const ContainerSelectionRoute();
@override
String? redirect(BuildContext context, GoRouterState state) {
return _isContainerUiEnabled(context)
? null
: const BrowserRoute().location;
}
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerSelectionScreen();
}
}
class ContainerEditRoute extends GoRouteData with $ContainerEditRoute {
final String containerData;
const ContainerEditRoute({required this.containerData});
@override
String? redirect(BuildContext context, GoRouterState state) {
return _isContainerUiEnabled(context)
? null
: const BrowserRoute().location;
}
@override
Widget build(BuildContext context, GoRouterState state) {
return ContainerEditScreen.edit(
initialContainer: ContainerDataWithCount.fromJson(
jsonDecode(containerData) as Map<String, dynamic>,
),
);
}
}
class ContainerCreateRoute extends GoRouteData with $ContainerCreateRoute {
final String containerData;
final String tabIds;
ContainerCreateRoute({required this.containerData, this.tabIds = '[]'});
@override
String? redirect(BuildContext context, GoRouterState state) {
return _isContainerUiEnabled(context)
? null
: const BrowserRoute().location;
}
@override
Widget build(BuildContext context, GoRouterState state) {
final tabIdsList = jsonDecode(tabIds) as List;
final tabIdsSet = tabIdsList.cast<String>().toSet();
return ContainerEditScreen.create(
initialContainer: ContainerData.fromJson(
jsonDecode(containerData) as Map<String, dynamic>,
),
tabIds: tabIdsSet.isNotEmpty ? tabIdsSet : null,
);
}
}
class ContextMenuRoute extends GoRouteData with $ContextMenuRoute {
final String hitResult;
const ContextMenuRoute({required this.hitResult});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder: (_) =>
ContextMenuDialog(hitResult: HitResultJson.fromJson(hitResult)),
);
}
}
class TabTreeRoute extends GoRouteData with $TabTreeRoute {
final String rootTabId;
const TabTreeRoute(this.rootTabId);
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => TabTreeDialog(rootTabId));
}
}
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
final String sharedUrl;
const OpenSharedContentRoute({this.sharedUrl = 'about:blank'});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return BottomSheetPage(
builder: (_) => OpenSharedContent(
sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'),
),
);
}
}
class TabViewRoute extends GoRouteData with $TabViewRoute {
const TabViewRoute();
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => const TabViewScreen());
}
}
class SelectProfileRoute extends GoRouteData with $SelectProfileRoute {
const SelectProfileRoute();
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return BottomSheetPage(builder: (_) => const SelectProfileDialog());
}
}
+151
View File
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
import 'package:weblibre/features/bangs/presentation/screens/edit.dart';
import 'package:weblibre/features/bangs/presentation/screens/menu.dart';
import 'package:weblibre/features/bangs/presentation/screens/search.dart';
import 'package:weblibre/features/bangs/presentation/screens/user.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/tab_view.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
import 'package:weblibre/features/onboarding/presentation/onboarding.dart';
import 'package:weblibre/features/settings/presentation/screens/addon_collection.dart';
import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/contextual_toolbar_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/custom_tracking_protection.dart';
import 'package:weblibre/features/settings/presentation/screens/doh_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/error_logs_screen.dart';
import 'package:weblibre/features/settings/presentation/screens/experimental_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/extensions_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/fingerprint_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/general_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/locale_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/search_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/settings.dart';
import 'package:weblibre/features/settings/presentation/screens/toolbar_layout_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart';
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart';
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_backup.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_backup_list.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_edit.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_list.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_restore.dart';
import 'package:weblibre/features/user/domain/presentation/widgets/auth_gate.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_feed/presentation/add_feed_dialog.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_article.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_article_list.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_edit.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_list.dart';
import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart';
part 'routes.bangs.dart';
part 'routes.bookmarks.dart';
part 'routes.browser.dart';
part 'routes.feeds.dart';
part 'routes.g.dart';
part 'routes.history.dart';
part 'routes.profiles.dart';
part 'routes.settings.dart';
part 'routes.tor.dart';
@TypedGoRoute<AboutRoute>(name: 'AboutRoute', path: '/about')
class AboutRoute extends GoRouteData with $AboutRoute {
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => const AboutDialogScreen());
}
}
@TypedGoRoute<OnboardingRoute>(
name: OnboardingRoute.name,
path: '${OnboardingRoute.pathPrefix}/:currentRevision/:targetRevision',
)
class OnboardingRoute extends GoRouteData with $OnboardingRoute {
static const name = 'OnboardingRoute';
static const pathPrefix = '/onboarding';
final int currentRevision;
final int targetRevision;
const OnboardingRoute({
required this.currentRevision,
required this.targetRevision,
});
@override
Widget build(BuildContext context, GoRouterState state) {
return OnboardingScreen(
currentRevision: currentRevision,
targetRevision: targetRevision,
);
}
}
@TypedGoRoute<LockRoute>(name: LockRoute.name, path: LockRoute.path)
class LockRoute extends GoRouteData with $LockRoute {
static const name = 'LockRoute';
static const path = '/lock';
const LockRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const LockScreen();
}
}
@@ -0,0 +1,130 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<FeedListRoute>(
name: 'FeedListRoute',
path: '/feeds',
routes: [
TypedGoRoute<FeedAddRoute>(name: FeedAddRoute.name, path: 'add'),
TypedGoRoute<FeedArticleListRoute>(
name: 'FeedArticleListRoute',
path: 'articles/:feedId',
),
TypedGoRoute<FeedArticleRoute>(
name: 'FeedArticleRoute',
path: 'article/:articleId',
),
TypedGoRoute<FeedCreateRoute>(
name: 'FeedCreateRoute',
path: 'create/:feedId',
),
TypedGoRoute<SelectFeedDialogRoute>(
name: 'SelectFeedDialogRoute',
path: 'available/:feedsJson',
),
TypedGoRoute<FeedEditRoute>(name: 'FeedEditRoute', path: 'edit/:feedId'),
],
)
class FeedListRoute extends GoRouteData with $FeedListRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const FeedListScreen();
}
}
class FeedCreateRoute extends GoRouteData with $FeedCreateRoute {
final Uri feedId;
FeedCreateRoute({required this.feedId});
@override
Widget build(BuildContext context, GoRouterState state) {
return FeedEditScreen.create(feedId: feedId);
}
}
class SelectFeedDialogRoute extends GoRouteData with $SelectFeedDialogRoute {
final String feedsJson;
const SelectFeedDialogRoute({required this.feedsJson});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
final feedUris = Set<Uri>.from(
(jsonDecode(feedsJson) as List<dynamic>).map(
(url) => Uri.parse(url as String),
),
);
return BottomSheetPage(
builder: (_) => SelectFeedDialog(feedUris: feedUris),
);
}
}
class FeedEditRoute extends GoRouteData with $FeedEditRoute {
final Uri feedId;
const FeedEditRoute({required this.feedId});
@override
Widget build(BuildContext context, GoRouterState state) {
return FeedEditScreen.edit(feedId: feedId);
}
}
class FeedAddRoute extends GoRouteData with $FeedAddRoute {
final String? uri;
static const name = 'FeedAddRoute';
const FeedAddRoute({required this.uri});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder: (_) =>
AddFeedDialog(initialUri: uri.mapNotNull((uri) => Uri.tryParse(uri))),
);
}
}
class FeedArticleListRoute extends GoRouteData with $FeedArticleListRoute {
final Uri feedId;
FeedArticleListRoute({required this.feedId});
@override
Widget build(BuildContext context, GoRouterState state) {
return FeedArticleListScreen(feedId: feedId);
}
}
class FeedArticleRoute extends GoRouteData with $FeedArticleRoute {
final String articleId;
FeedArticleRoute({required this.articleId});
@override
Widget build(BuildContext context, GoRouterState state) {
return FeedArticleScreen(articleId: articleId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<HistoryRoute>(
name: 'HistoryRoute',
path: '/history',
routes: [
TypedGoRoute<HistoryDownloadsRoute>(
name: 'HistoryDownloadsRoute',
path: 'downloads',
),
],
)
class HistoryRoute extends GoRouteData with $HistoryRoute {
const HistoryRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HistoryScreen();
}
}
class HistoryDownloadsRoute extends GoRouteData with $HistoryDownloadsRoute {
const HistoryDownloadsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HistoryScreen(mode: HistoryScreenMode.downloads);
}
}
@@ -0,0 +1,101 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<ProfileListRoute>(
name: 'ProfileListRoute',
path: '/profiles',
routes: [
TypedGoRoute<EditProfileRoute>(name: 'ProfileEditRoute', path: 'edit'),
TypedGoRoute<ProfileBackupListRoute>(
name: 'ProfileBackupListRoute',
path: 'backup_list',
),
TypedGoRoute<RestoreProfileRoute>(
name: 'RestoreProfileRoute',
path: 'restore',
),
TypedGoRoute<BackupProfileRoute>(
name: 'BackupProfileRoute',
path: 'backup',
),
TypedGoRoute<CreateProfileRoute>(
name: 'CreateProfileRoute',
path: 'create',
),
],
)
class ProfileListRoute extends GoRouteData with $ProfileListRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ProfileListScreen();
}
}
class CreateProfileRoute extends GoRouteData with $CreateProfileRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ProfileEditScreen(profile: null);
}
}
class EditProfileRoute extends GoRouteData with $EditProfileRoute {
final String profile;
const EditProfileRoute({required this.profile});
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfileEditScreen(
profile: Profile.fromJson(jsonDecode(profile) as Map<String, dynamic>),
);
}
}
class BackupProfileRoute extends GoRouteData with $BackupProfileRoute {
final String profile;
const BackupProfileRoute({required this.profile});
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfileBackupScreen(
profile: Profile.fromJson(jsonDecode(profile) as Map<String, dynamic>),
);
}
}
class RestoreProfileRoute extends GoRouteData with $RestoreProfileRoute {
final String backupFileUri;
const RestoreProfileRoute({required this.backupFileUri});
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfileRestoreScreen(backupFileUri: Uri.parse(backupFileUri));
}
}
class ProfileBackupListRoute extends GoRouteData with $ProfileBackupListRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ProfileBackupListScreen();
}
}
@@ -0,0 +1,295 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<SettingsRoute>(
name: 'SettingsRoute',
path: '/settings',
routes: [
TypedGoRoute<GeneralSettingsRoute>(
name: 'GeneralSettingsRoute',
path: 'general',
),
TypedGoRoute<BrowsingSettingsRoute>(
name: 'BrowsingSettingsRoute',
path: 'browsing',
),
TypedGoRoute<PrivacySecuritySettingsRoute>(
name: 'PrivacySecuritySettingsRoute',
path: 'privacy_security',
),
TypedGoRoute<ToolbarLayoutSettingsRoute>(
name: 'ToolbarLayoutSettingsRoute',
path: 'toolbar_layout',
),
TypedGoRoute<WebContentSettingsRoute>(
name: 'WebContentSettingsRoute',
path: 'web_content',
),
TypedGoRoute<SearchSettingsRoute>(
name: 'SearchSettingsRoute',
path: 'search',
),
TypedGoRoute<ExtensionsSettingsRoute>(
name: 'ExtensionsSettingsRoute',
path: 'extensions',
),
TypedGoRoute<AdvancedSettingsRoute>(
name: 'AdvancedSettingsRoute',
path: 'advanced',
),
TypedGoRoute<ExperimentalSettingsRoute>(
name: 'ExperimentalSettingsRoute',
path: 'experimental',
),
TypedGoRoute<BangSettingsRoute>(name: 'BangSettingsRoute', path: 'bang'),
TypedGoRoute<WebEngineHardeningRoute>(
name: 'WebEngineHardeningRoute',
path: 'hardening',
routes: [
TypedGoRoute<WebEngineHardeningGroupRoute>(
name: 'WebEngineHardeningGroupRoute',
path: 'group/:group',
),
],
),
TypedGoRoute<DohSettingsRoute>(name: 'DohSettingsRoute', path: 'doh'),
TypedGoRoute<FingerprintSettingsRoute>(
name: 'FingerprintSettingsRoute',
path: 'fingerprint',
),
TypedGoRoute<LocaleSettingsRoute>(
name: 'LocaleSettingsRoute',
path: 'locales',
),
TypedGoRoute<AddonCollectionRoute>(
name: 'AddonCollectionRoute',
path: 'addon_collection',
),
TypedGoRoute<TrackingProtectionExceptionsRoute>(
name: 'TrackingProtectionExceptionsRoute',
path: 'tracking_protection_exceptions',
),
TypedGoRoute<CustomTrackingProtectionRoute>(
name: 'CustomTrackingProtectionRoute',
path: 'custom_tracking_protection',
),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
TypedGoRoute<UrlCleanerSettingsRoute>(
name: 'UrlCleanerSettingsRoute',
path: 'url_cleaner',
),
TypedGoRoute<UnshortenerSettingsRoute>(
name: 'UnshortenerSettingsRoute',
path: 'unshortener',
),
TypedGoRoute<ContextualToolbarSettingsRoute>(
name: 'ContextualToolbarSettingsRoute',
path: 'contextual_toolbar',
),
],
)
class SettingsRoute extends GoRouteData with $SettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SettingsScreen();
}
}
class GeneralSettingsRoute extends GoRouteData with $GeneralSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const GeneralSettingsScreen();
}
}
class BrowsingSettingsRoute extends GoRouteData with $BrowsingSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const BrowsingSettingsScreen();
}
}
class PrivacySecuritySettingsRoute extends GoRouteData
with $PrivacySecuritySettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const PrivacySecuritySettingsScreen();
}
}
class ToolbarLayoutSettingsRoute extends GoRouteData
with $ToolbarLayoutSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ToolbarLayoutSettingsScreen();
}
}
class WebContentSettingsRoute extends GoRouteData
with $WebContentSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const WebContentSettingsScreen();
}
}
class SearchSettingsRoute extends GoRouteData with $SearchSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SearchSettingsScreen();
}
}
class ExtensionsSettingsRoute extends GoRouteData
with $ExtensionsSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ExtensionsSettingsScreen();
}
}
class AdvancedSettingsRoute extends GoRouteData with $AdvancedSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const AdvancedSettingsScreen();
}
}
class ExperimentalSettingsRoute extends GoRouteData
with $ExperimentalSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ExperimentalSettingsScreen();
}
}
class BangSettingsRoute extends GoRouteData with $BangSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const BangSettingsScreen();
}
}
class DohSettingsRoute extends GoRouteData with $DohSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const DohSettingsScreen();
}
}
class FingerprintSettingsRoute extends GoRouteData
with $FingerprintSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const FingerprintSettingsScreen();
}
}
class LocaleSettingsRoute extends GoRouteData with $LocaleSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const LocaleSettingsScreen();
}
}
class AddonCollectionRoute extends GoRouteData with $AddonCollectionRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const AddonCollectionScreen();
}
}
class WebEngineHardeningRoute extends GoRouteData
with $WebEngineHardeningRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const WebEngineHardeningScreen();
}
}
class WebEngineHardeningGroupRoute extends GoRouteData
with $WebEngineHardeningGroupRoute {
final String group;
const WebEngineHardeningGroupRoute({required this.group});
@override
Widget build(BuildContext context, GoRouterState state) {
return WebEngineHardeningGroupScreen(groupName: group);
}
}
class TrackingProtectionExceptionsRoute extends GoRouteData
with $TrackingProtectionExceptionsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const TrackingProtectionExceptionsScreen();
}
}
class ErrorLogsRoute extends GoRouteData with $ErrorLogsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ErrorLogsScreen();
}
}
class CustomTrackingProtectionRoute extends GoRouteData
with $CustomTrackingProtectionRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const CustomTrackingProtectionScreen();
}
}
class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const SyncSettingsScreen();
}
}
class UrlCleanerSettingsRoute extends GoRouteData
with $UrlCleanerSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const UrlCleanerSettingsScreen();
}
}
class UnshortenerSettingsRoute extends GoRouteData
with $UnshortenerSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const UnshortenerSettingsScreen();
}
}
class ContextualToolbarSettingsRoute extends GoRouteData
with $ContextualToolbarSettingsRoute {
const ContextualToolbarSettingsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContextualToolbarSettingsScreen();
}
}
@@ -0,0 +1,51 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<TorProxyRoute>(
name: 'TorProxyRoute',
path: '/tor',
routes: [
TypedGoRoute<TorCountryPickerRoute>(
name: 'TorCountryPickerRoute',
path: 'country_picker',
),
],
)
class TorProxyRoute extends GoRouteData with $TorProxyRoute {
const TorProxyRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const TorProxyScreen();
}
}
class TorCountryPickerRoute extends GoRouteData with $TorCountryPickerRoute {
final String title;
final String? $extra;
const TorCountryPickerRoute({required this.title, this.$extra});
@override
Widget build(BuildContext context, GoRouterState state) {
return CountryPickerScreen(title: title, selectedCountryCode: $extra);
}
}
@@ -0,0 +1,63 @@
/*
* 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';
/// A bottom sheet page with Material entrance and exit animations.
/// Similar to DialogPage but displays content as a modal bottom sheet.
class BottomSheetPage<T> extends Page<T> {
final WidgetBuilder builder;
final Color? barrierColor;
final bool barrierDismissible;
final String? barrierLabel;
final bool isScrollControlled;
final bool useSafeArea;
const BottomSheetPage({
required this.builder,
this.barrierColor,
this.barrierDismissible = true,
this.barrierLabel,
this.isScrollControlled = true,
this.useSafeArea = true,
super.key,
super.name,
super.arguments,
super.restorationId,
});
@override
Route<T> createRoute(BuildContext context) => ModalBottomSheetRoute<T>(
settings: this,
builder: builder,
barrierLabel:
barrierLabel ??
MaterialLocalizations.of(context).modalBarrierDismissLabel,
backgroundColor: Theme.of(context).bottomSheetTheme.modalBackgroundColor,
elevation: Theme.of(context).bottomSheetTheme.modalElevation,
shape: Theme.of(context).bottomSheetTheme.shape,
clipBehavior: Clip.antiAlias,
constraints: Theme.of(context).bottomSheetTheme.constraints,
modalBarrierColor:
barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor,
isScrollControlled: isScrollControlled,
isDismissible: barrierDismissible,
useSafeArea: useSafeArea,
);
}
@@ -0,0 +1,63 @@
/*
* 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';
/// A dialog page with Material entrance and exit animations, modal barrier color,
/// and modal barrier behavior (dialog is dismissible with a tap on the barrier).
class DialogPage<T> extends Page<T> {
final Offset? anchorPoint;
final Color? barrierColor;
final bool barrierDismissible;
final String? barrierLabel;
final bool useSafeArea;
final CapturedThemes? themes;
final WidgetBuilder builder;
const DialogPage({
required this.builder,
this.anchorPoint,
this.barrierColor,
this.barrierDismissible = true,
this.barrierLabel,
this.useSafeArea = true,
this.themes,
super.key,
super.name,
super.arguments,
super.restorationId,
});
@override
Route<T> createRoute(BuildContext context) => DialogRoute<T>(
context: context,
settings: this,
builder: builder,
anchorPoint: anchorPoint,
barrierColor:
barrierColor ??
DialogTheme.of(context).barrierColor ??
Theme.of(context).dialogTheme.barrierColor ??
Colors.black54,
barrierDismissible: barrierDismissible,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
themes: themes,
);
}
+23
View File
@@ -0,0 +1,23 @@
/*
* 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/>.
*/
/// Reusable sort directions for list items that share common sortable fields
/// (title, URL, date).
enum SortField { titleAsc, titleDesc, urlAsc, urlDesc, dateAsc, dateDesc }
+24
View File
@@ -0,0 +1,24 @@
/*
* 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:uuid/data.dart';
import 'package:uuid/rng.dart';
import 'package:uuid/uuid.dart';
const uuid = Uuid(goptions: GlobalOptions(CryptoRNG()));
@@ -0,0 +1,51 @@
/*
* 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:ui';
import 'package:drift/drift.dart';
import 'package:json_annotation/json_annotation.dart';
class ColorJsonConverter implements JsonConverter<Color, int> {
const ColorJsonConverter();
@override
Color fromJson(int from) {
return Color(from);
}
@override
int toJson(Color value) {
return value.toARGB32();
}
}
class ColorConverter extends TypeConverter<Color, int> {
const ColorConverter();
@override
Color fromSql(int fromDb) {
return Color(fromDb);
}
@override
int toSql(Color value) {
return value.toARGB32();
}
}
@@ -0,0 +1,46 @@
/*
* 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:json_annotation/json_annotation.dart';
class DateTimeRangeConverter
implements JsonConverter<DateTimeRange?, Map<String, dynamic>?> {
const DateTimeRangeConverter();
@override
DateTimeRange? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
return DateTimeRange(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
);
}
@override
Map<String, dynamic>? toJson(DateTimeRange? dateTimeRange) {
if (dateTimeRange == null) return null;
return {
'start': dateTimeRange.start.toIso8601String(),
'end': dateTimeRange.end.toIso8601String(),
};
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/widgets.dart';
import 'package:json_annotation/json_annotation.dart';
class IconDataJsonConverter
implements JsonConverter<IconData, Map<String, dynamic>> {
const IconDataJsonConverter();
@override
IconData fromJson(Map<String, dynamic> json) {
return IconData(
json['codePoint'] as int,
fontFamily: json['fontFamily'] as String,
fontPackage: json['fontPackage'] as String,
);
}
@override
Map<String, dynamic> toJson(IconData iconData) {
return <String, dynamic>{
'codePoint': iconData.codePoint,
'fontFamily': iconData.fontFamily,
'fontPackage': iconData.fontPackage,
};
}
}
class IconDataTypeConverter implements TypeConverter<IconData, String> {
const IconDataTypeConverter();
@override
IconData fromSql(String fromDb) {
final json = jsonDecode(fromDb) as Map<String, dynamic>;
return const IconDataJsonConverter().fromJson(json);
}
@override
String toSql(IconData value) {
assert(
value.fontFamily != null,
'Font family must be provided to identify icon',
);
return jsonEncode(const IconDataJsonConverter().toJson(value));
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
class StringListConverter extends TypeConverter<List<String>, String> {
const StringListConverter();
@override
List<String> fromSql(String fromDb) {
return (jsonDecode(fromDb) as List).cast<String>();
}
@override
String toSql(List<String> value) {
return jsonEncode(value);
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class UriConverter extends TypeConverter<Uri, String> {
const UriConverter();
@override
Uri fromSql(String fromDb) {
return uri_parser.tryParseUrl(fromDb, eagerParsing: true) ??
Uri.parse(fromDb);
}
@override
String toSql(Uri value) {
return value.toString();
}
}
class UriConverterNullable extends TypeConverter<Uri?, String?> {
const UriConverterNullable();
@override
Uri? fromSql(String? fromDb) {
return uri_parser.tryParseUrl(fromDb, eagerParsing: true);
}
@override
String? toSql(Uri? value) {
return value?.toString();
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
extension TableSize on DatabaseConnectionUser {
SingleSelectable<double> tableSize(TableInfo table) {
return customSelect(
'SELECT SUM(pgsize) /(1024.0 * 1024.0)AS pgsize_mb FROM dbstat WHERE name = ?1',
variables: [Variable<String>(table.actualTableName)],
readsFrom: {table},
).map((QueryRow row) => row.read<double>('pgsize_mb'));
}
}
@@ -0,0 +1,96 @@
/*
* 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:lexo_rank/lexo_rank.dart';
import 'package:lexo_rank/lexo_rank/lexo_rank_bucket.dart';
import 'package:nullability/nullability.dart';
import 'package:sqlite3/common.dart';
String _nextRankOrMiddle(List<Object?> args) {
final parsedBucket = LexoRankBucket.resolve(args[0]! as int);
if (args[1] != null) {
final parsedRank = LexoRank.parse(args[1]! as String);
return parsedRank.genNext().value;
} else {
return LexoRank.middle(bucket: parsedBucket).value;
}
}
String _previousRankOrMiddle(List<Object?> args) {
final parsedBucket = LexoRankBucket.resolve(args[0]! as int);
if (args[1] != null) {
final parsedRank = LexoRank.parse(args[1]! as String);
return parsedRank.genPrev().value;
} else {
return LexoRank.middle(bucket: parsedBucket).value;
}
}
String _reorderAfter(List<Object?> args) {
final first = args[0].mapNotNull((arg) => LexoRank.parse(arg as String));
final last = args[1].mapNotNull((arg) => LexoRank.parse(arg as String));
if (first == null) {
throw Exception('Tab not found');
} else if (last == null) {
return first.genNext().value;
} else {
return first.genBetween(last).value;
}
}
String _reorderBefore(List<Object?> args) {
final first = args[0].mapNotNull((arg) => LexoRank.parse(arg as String));
final last = args[1].mapNotNull((arg) => LexoRank.parse(arg as String));
if (first == null) {
throw Exception('Tab not found');
} else if (last == null) {
return first.genPrev().value;
} else {
return last.genBetween(first).value;
}
}
void registerLexorankFunctions(CommonDatabase database) {
database.createFunction(
functionName: 'lexo_rank_next',
argumentCount: const AllowedArgumentCount(2),
function: _nextRankOrMiddle,
);
database.createFunction(
functionName: 'lexo_rank_previous',
argumentCount: const AllowedArgumentCount(2),
function: _previousRankOrMiddle,
);
database.createFunction(
functionName: 'lexo_rank_reorder_after',
argumentCount: const AllowedArgumentCount(2),
function: _reorderAfter,
);
database.createFunction(
functionName: 'lexo_rank_reorder_before',
argumentCount: const AllowedArgumentCount(2),
function: _reorderBefore,
);
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
sealed class DropTargetData with FastEquatable {}
final class ContainerDropData extends DropTargetData {
final String tabId;
ContainerDropData(this.tabId);
@override
List<Object?> get hashParameters => [tabId];
}
final class DeleteDropData extends DropTargetData {
final String tabId;
DeleteDropData(this.tabId);
@override
List<Object?> get hashParameters => [tabId];
}
sealed class DragTargetData with FastEquatable {}
final class TabDragData extends DragTargetData {
final String tabId;
TabDragData(this.tabId);
@override
List<Object?> get hashParameters => [tabId];
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class ReceivedIntentParameter with FastEquatable {
final String? content;
final String? tool;
final String? contextId;
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
@override
List<Object?> get hashParameters => [content, tool, contextId];
}
@@ -0,0 +1,41 @@
/*
* 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:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
part 'web_page_info.g.dart';
@CopyWith()
class WebPageInfo with FastEquatable {
final Uri url;
final String? title;
final BrowserIcon? favicon;
final Set<Uri>? feeds;
bool get isPageInfoComplete =>
title.isNotEmpty && favicon != null && feeds != null;
WebPageInfo({required this.url, this.title, this.favicon, this.feeds});
@override
List<Object?> get hashParameters => [url, title, favicon, feeds];
}
@@ -0,0 +1,92 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_page_info.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$WebPageInfoCWProxy {
WebPageInfo url(Uri url);
WebPageInfo title(String? title);
WebPageInfo favicon(BrowserIcon? favicon);
WebPageInfo feeds(Set<Uri>? feeds);
/// 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 `WebPageInfo(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebPageInfo(...).copyWith(id: 12, name: "My name")
/// ```
WebPageInfo call({
Uri url,
String? title,
BrowserIcon? favicon,
Set<Uri>? feeds,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfWebPageInfo.copyWith(...)` or call `instanceOfWebPageInfo.copyWith.fieldName(value)` for a single field.
class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy {
const _$WebPageInfoCWProxyImpl(this._value);
final WebPageInfo _value;
@override
WebPageInfo url(Uri url) => call(url: url);
@override
WebPageInfo title(String? title) => call(title: title);
@override
WebPageInfo favicon(BrowserIcon? favicon) => call(favicon: favicon);
@override
WebPageInfo feeds(Set<Uri>? feeds) => call(feeds: feeds);
@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 `WebPageInfo(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebPageInfo(...).copyWith(id: 12, name: "My name")
/// ```
WebPageInfo call({
Object? url = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? favicon = const $CopyWithPlaceholder(),
Object? feeds = const $CopyWithPlaceholder(),
}) {
return WebPageInfo(
url: url == const $CopyWithPlaceholder() || url == null
? _value.url
// ignore: cast_nullable_to_non_nullable
: url as Uri,
title: title == const $CopyWithPlaceholder()
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String?,
favicon: favicon == const $CopyWithPlaceholder()
? _value.favicon
// ignore: cast_nullable_to_non_nullable
: favicon as BrowserIcon?,
feeds: feeds == const $CopyWithPlaceholder()
? _value.feeds
// ignore: cast_nullable_to_non_nullable
: feeds as Set<Uri>?,
);
}
}
extension $WebPageInfoCopyWith on WebPageInfo {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfWebPageInfo.copyWith(...)` or `instanceOfWebPageInfo.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$WebPageInfoCWProxy get copyWith => _$WebPageInfoCWProxyImpl(this);
}
@@ -0,0 +1,56 @@
/*
* 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:ui';
class EquatableImage {
Image? _value;
final int _imageHash;
bool _isDisposed = false;
EquatableImage(Image value, {required int hash})
: _value = value,
_imageHash = hash;
/// The underlying ui.Image. Returns null if disposed.
Image? get value => _isDisposed ? null : _value;
/// Whether this image has been disposed.
bool get isDisposed => _isDisposed;
/// Disposes the underlying ui.Image to free GPU memory.
/// This is safe to call multiple times.
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
// Delay disposal to allow widgets to finish rendering
Future.delayed(const Duration(seconds: 3), () {
_value?.dispose();
_value = null;
});
}
@override
int get hashCode => _imageHash.hashCode;
@override
bool operator ==(Object other) {
return other is EquatableImage && other._imageHash == _imageHash;
}
}
@@ -0,0 +1,59 @@
/*
* 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:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:uuid/uuid_value.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/user/data/models/auth_settings.dart';
part 'profile.g.dart';
@JsonSerializable()
@CopyWith()
class Profile with FastEquatable {
@CopyWithField(immutable: true)
final String id;
final String name;
final AuthSettings authSettings;
late final uuidValue = UuidValue.fromString(id);
static String getNewProfileId() => uuid.v7();
Profile({required this.id, required this.name, AuthSettings? authSettings})
: authSettings = authSettings ?? AuthSettings.withDefaults();
factory Profile.create({required String name, AuthSettings? authSettings}) {
return Profile(
id: getNewProfileId(),
name: name,
authSettings: authSettings,
);
}
@override
List<Object?> get hashParameters => [id, name, authSettings];
factory Profile.fromJson(Map<String, dynamic> json) =>
_$ProfileFromJson(json);
Map<String, dynamic> toJson() => _$ProfileToJson(this);
}
@@ -0,0 +1,87 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'profile.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$ProfileCWProxy {
Profile name(String name);
Profile authSettings(AuthSettings? authSettings);
/// 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 `Profile(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// Profile(...).copyWith(id: 12, name: "My name")
/// ```
Profile call({String name, AuthSettings? authSettings});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfProfile.copyWith(...)` or call `instanceOfProfile.copyWith.fieldName(value)` for a single field.
class _$ProfileCWProxyImpl implements _$ProfileCWProxy {
const _$ProfileCWProxyImpl(this._value);
final Profile _value;
@override
Profile name(String name) => call(name: name);
@override
Profile authSettings(AuthSettings? authSettings) =>
call(authSettings: authSettings);
@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 `Profile(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// Profile(...).copyWith(id: 12, name: "My name")
/// ```
Profile call({
Object? name = const $CopyWithPlaceholder(),
Object? authSettings = const $CopyWithPlaceholder(),
}) {
return Profile(
id: _value.id,
name: name == const $CopyWithPlaceholder() || name == null
? _value.name
// ignore: cast_nullable_to_non_nullable
: name as String,
authSettings: authSettings == const $CopyWithPlaceholder()
? _value.authSettings
// ignore: cast_nullable_to_non_nullable
: authSettings as AuthSettings?,
);
}
}
extension $ProfileCopyWith on Profile {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfProfile.copyWith(...)` or `instanceOfProfile.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$ProfileCWProxy get copyWith => _$ProfileCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Profile _$ProfileFromJson(Map<String, dynamic> json) => Profile(
id: json['id'] as String,
name: json['name'] as String,
authSettings: json['authSettings'] == null
? null
: AuthSettings.fromJson(json['authSettings'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ProfileToJson(Profile instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'authSettings': instance.authSettings.toJson(),
};
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart' hide Locale;
import 'package:intl/locale.dart' as intl;
import 'package:locale_resolver/locale_resolver.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/extensions/locale.dart';
part 'locale_resolver.g.dart';
@Riverpod(keepAlive: true)
class LocaleResolverRepository extends _$LocaleResolverRepository {
final _service = LocaleResolver();
final _cache = <intl.Locale, LocalizedResult>{};
Future<LocalizedResult> resolve(intl.Locale locale) async {
final cached = _cache[locale];
if (cached != null) {
return cached;
}
return _cache[locale] = await _service.resolve(
locale.toLanguageTag(),
targetLocale.toLanguageTag(),
);
}
@override
void build(intl.Locale targetLocale) {}
}
@Riverpod()
Future<LocalizedResult> resolveLocale(Ref ref, intl.Locale locale) {
return ref
.read(
localeResolverRepositoryProvider(
WidgetsBinding.instance.platformDispatcher.locale.toIntlLocale(),
).notifier,
)
.resolve(locale);
}
@@ -0,0 +1,184 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'locale_resolver.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(LocaleResolverRepository)
final localeResolverRepositoryProvider = LocaleResolverRepositoryFamily._();
final class LocaleResolverRepositoryProvider
extends $NotifierProvider<LocaleResolverRepository, void> {
LocaleResolverRepositoryProvider._({
required LocaleResolverRepositoryFamily super.from,
required intl.Locale super.argument,
}) : super(
retry: null,
name: r'localeResolverRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$localeResolverRepositoryHash();
@override
String toString() {
return r'localeResolverRepositoryProvider'
''
'($argument)';
}
@$internal
@override
LocaleResolverRepository create() => LocaleResolverRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
@override
bool operator ==(Object other) {
return other is LocaleResolverRepositoryProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$localeResolverRepositoryHash() =>
r'dc74908d6ac1f5cc851e2a10dcf33b0dc68b5b15';
final class LocaleResolverRepositoryFamily extends $Family
with
$ClassFamilyOverride<
LocaleResolverRepository,
void,
void,
void,
intl.Locale
> {
LocaleResolverRepositoryFamily._()
: super(
retry: null,
name: r'localeResolverRepositoryProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
LocaleResolverRepositoryProvider call(intl.Locale targetLocale) =>
LocaleResolverRepositoryProvider._(argument: targetLocale, from: this);
@override
String toString() => r'localeResolverRepositoryProvider';
}
abstract class _$LocaleResolverRepository extends $Notifier<void> {
late final _$args = ref.$arg as intl.Locale;
intl.Locale get targetLocale => _$args;
void build(intl.Locale targetLocale);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(resolveLocale)
final resolveLocaleProvider = ResolveLocaleFamily._();
final class ResolveLocaleProvider
extends
$FunctionalProvider<
AsyncValue<LocalizedResult>,
LocalizedResult,
FutureOr<LocalizedResult>
>
with $FutureModifier<LocalizedResult>, $FutureProvider<LocalizedResult> {
ResolveLocaleProvider._({
required ResolveLocaleFamily super.from,
required intl.Locale super.argument,
}) : super(
retry: null,
name: r'resolveLocaleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$resolveLocaleHash();
@override
String toString() {
return r'resolveLocaleProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<LocalizedResult> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<LocalizedResult> create(Ref ref) {
final argument = this.argument as intl.Locale;
return resolveLocale(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ResolveLocaleProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$resolveLocaleHash() => r'94d7a9b307a81de372b8b40b06f046acc76e01c8';
final class ResolveLocaleFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<LocalizedResult>, intl.Locale> {
ResolveLocaleFamily._()
: super(
retry: null,
name: r'resolveLocaleProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ResolveLocaleProvider call(intl.Locale locale) =>
ResolveLocaleProvider._(argument: locale, from: this);
@override
String toString() => r'resolveLocaleProvider';
}
@@ -0,0 +1,94 @@
/*
* 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 'package:exceptions/exceptions.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/providers/format.dart';
import 'package:weblibre/features/about/domain/providers.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
part 'app_initialization.g.dart';
@Riverpod(keepAlive: true)
class AppInitializationService extends _$AppInitializationService {
/// Will de facto restart the app
Future<void> reinitialize() {
ref.invalidateSelf();
return initialize();
}
Future<void> _initPackageInfo() {
//Ensure Package info is loaded
state = Result.success((
initialized: false,
stage: 'Loading Package Info...',
errors: List.empty(),
));
return ref.read(packageInfoProvider.future);
}
Future<Map<BangGroup, Result<void>>> _initBangs() {
state = Result.success((
initialized: false,
stage: 'Synchronizing Bangs...',
errors: List.empty(),
));
return ref
.read(bangSyncRepositoryProvider.notifier)
.syncBundledBangGroups();
}
Future<void> initialize() async {
state = await Result.fromAsync(() async {
final errors = <ErrorMessage>[];
await ref.read(formatProvider.future);
if (!ref.mounted) {
return (initialized: false, stage: null, errors: errors);
}
await _initPackageInfo();
if (!ref.mounted) {
return (initialized: false, stage: null, errors: errors);
}
final bangSyncResults = await _initBangs();
for (final MapEntry(value: result) in bangSyncResults.entries) {
result.onFailure(errors.add);
}
return (initialized: true, stage: null, errors: errors);
});
}
@override
Result<({bool initialized, String? stage, List<ErrorMessage> errors})>
build() {
return Result.success((
initialized: false,
stage: null,
errors: List.empty(),
));
}
}
@@ -0,0 +1,98 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_initialization.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AppInitializationService)
final appInitializationServiceProvider = AppInitializationServiceProvider._();
final class AppInitializationServiceProvider
extends
$NotifierProvider<
AppInitializationService,
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
> {
AppInitializationServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appInitializationServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appInitializationServiceHash();
@$internal
@override
AppInitializationService create() => AppInitializationService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
value,
) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>
>(value),
);
}
}
String _$appInitializationServiceHash() =>
r'c26f968d53b4ea18f7be890c9610c9bf9d300322';
abstract class _$AppInitializationService
extends
$Notifier<
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
> {
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>,
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>,
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>
>,
Result<
({List<ErrorMessage> errors, bool initialized, String? stage})
>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,407 @@
/*
* 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:io';
import 'dart:ui';
import 'package:exceptions/exceptions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:http/io_client.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:socks5_proxy/socks_client.dart';
import 'package:weblibre/core/http_error_handler.dart';
import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/extensions/http_encoding.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
import 'package:weblibre/features/user/domain/repositories/cache.dart';
import 'package:weblibre/features/web_feed/utils/feed_finder.dart';
import 'package:weblibre/utils/lru_cache.dart';
part 'generic_website.g.dart';
const _typeMap = {
"manifest": IconType.manifestIcon,
"icon": IconType.favicon,
"shortcut icon": IconType.favicon,
"fluid-icon": IconType.fluidIcon,
"apple-touch-icon": IconType.appleTouchIcon,
"image_src": IconType.imageSrc,
"apple-touch-icon image_src": IconType.appleTouchIcon,
"apple-touch-icon-precomposed": IconType.appleTouchIcon,
"og:image": IconType.openGraph,
"og:image:url": IconType.openGraph,
"og:image:secure_url": IconType.openGraph,
"twitter:image": IconType.twitter,
"msapplication-TileImage": IconType.microsoftTile,
};
final class _InFlightFetch {
final Future<Result<WebPageInfo>> future;
const _InFlightFetch(this.future);
}
Iterable<ResourceSize> sizesToList(String? sizes) sync* {
if (sizes != null) {
final splitted = sizes
.split(' ')
.where((size) => size.contains('x'))
.toList();
for (final size in splitted) {
final dimensions = size.split('x');
if (dimensions.length == 2) {
final height = int.tryParse(dimensions[0]);
final width = int.tryParse(dimensions[1]);
if (width != null && height != null) {
yield ResourceSize(height: height, width: width);
}
}
}
}
}
@Riverpod(keepAlive: true)
class GenericWebsiteService extends _$GenericWebsiteService {
final GeckoIconService _iconsService;
late CacheRepository _cacheRepository;
late final LRUCache<String, BrowserIcon> _browserIconCache;
final _inFlightFetches = <Uri, _InFlightFetch>{};
GenericWebsiteService()
: _iconsService = GeckoIconService(),
_browserIconCache = LRUCache(50, onEvict: (icon) => icon.image.dispose());
@override
void build() {
_cacheRepository = ref.watch(cacheRepositoryProvider.notifier);
}
static Map<String, dynamic> _serializeResource(Resource resource) {
return {
'url': resource.url,
'type': resource.type,
'sizes': resource.sizes.nonNulls.map((s) => [s.height, s.width]).toList(),
'mimeType': resource.mimeType,
'maskable': resource.maskable,
};
}
static Resource _deserializeResource(Map<String, dynamic> resource) {
return Resource(
url: resource['url'] as String,
type: resource['type'] as IconType,
mimeType: resource['mimeType'] as String?,
sizes: (resource['sizes'] as List<List<int>>)
.map((s) => ResourceSize(height: s[0], width: s[1]))
.toList(),
maskable: resource['maskable'] as bool,
);
}
static Uri _resolveRelativeUri(Uri baseUri, Uri uri) {
if (!uri.isAbsolute) {
return baseUri.resolveUri(uri);
}
return uri;
}
static bool _isHttpUrl(Uri url) => url.isHttpOrHttps;
static List<Resource> _extractIcons(Uri baseUrl, Document document) {
final List<Resource> icons = [];
void collectLinkIcons(String rel) {
final links = document.querySelectorAll('link[rel="$rel"]');
for (final link in links) {
final href = link.attributes['href'];
final type = _typeMap[rel];
final mimeType = link.attributes['type'];
if (href != null && type != null) {
if (Uri.tryParse(href) case final Uri url) {
icons.add(
Resource(
url: _resolveRelativeUri(baseUrl, url).toString(),
type: type,
sizes: sizesToList(link.attributes['sizes']).toList(),
mimeType: mimeType.isNotEmpty ? mimeType : null,
maskable: false,
),
);
}
}
}
}
void collectMetaPropertyIcons(String property) {
final metas = document.querySelectorAll('meta[property="$property"]');
for (final meta in metas) {
final content = meta.attributes['content'];
final type = _typeMap[property];
if (content != null && type != null) {
if (Uri.tryParse(content) case final Uri url) {
icons.add(
Resource(
type: type,
url: _resolveRelativeUri(baseUrl, url).toString(),
sizes: [],
maskable: false,
),
);
}
}
}
}
void collectMetaNameIcons(String name) {
final metas = document.querySelectorAll('meta[name="$name"]');
for (final meta in metas) {
final content = meta.attributes['content'];
final type = _typeMap[name];
if (content != null && type != null) {
if (Uri.tryParse(content) case final Uri url) {
icons.add(
Resource(
type: type,
url: _resolveRelativeUri(baseUrl, url).toString(),
sizes: [],
maskable: false,
),
);
}
}
}
}
collectLinkIcons("icon");
collectLinkIcons("shortcut icon");
collectLinkIcons("fluid-icon");
collectLinkIcons("apple-touch-icon");
collectLinkIcons("image_src");
collectLinkIcons("apple-touch-icon image_src");
collectLinkIcons("apple-touch-icon-precomposed");
collectMetaPropertyIcons("og:image");
collectMetaPropertyIcons("og:image:url");
collectMetaPropertyIcons("og:image:secure_url");
collectMetaNameIcons("twitter:image");
collectMetaNameIcons("msapplication-TileImage");
return icons;
}
Future<Result<WebPageInfo>> fetchPageInfo({
required Uri url,
required bool isImageRequest,
required int? proxyPort,
}) {
return Result.fromAsync(() async {
final result = await compute((args) async {
final [String urlString, bool isImageRequest, int? proxyPort] = args;
final httpClient = HttpClient();
if (proxyPort != null) {
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(InternetAddress.loopbackIPv4, proxyPort),
]);
}
final client = IOClient(httpClient);
try {
final baseUri = Uri.parse(urlString);
final response = await client
.get(baseUri)
.timeout(const Duration(seconds: 15));
//When this is a request for an icon and we hit an image, directly return it
if (isImageRequest) {
final contentType = response.headers['content-type'];
if (contentType?.contains('image/') == true) {
return {
'imageBytes': [response.bodyBytes],
};
}
}
final document = html_parser.parse(response.bodyUnicodeFallback);
final title = document.querySelector('title')?.text;
final resources = _extractIcons(baseUri, document);
final feeds = await FeedFinder(
url: baseUri,
document: document,
).parse();
return {
'title': title,
'resources': resources.map(_serializeResource).toList(),
'feeds': feeds.map((uri) => uri.toString()).toList(),
};
} finally {
client.close();
}
}, <dynamic>[url.toString(), isImageRequest, proxyPort]);
if (result['imageBytes'] case final Uint8List imageBytes) {
return WebPageInfo(
url: url,
favicon: await BrowserIcon.fromBytes(
imageBytes,
dominantColor: null,
source: IconSource.download,
),
);
}
final resources = (result['resources']! as List<Map<String, dynamic>>)
.map(_deserializeResource)
.toList();
final favicon =
await getCachedIcon(url) ??
await loadIcon(url: url, resources: resources);
return WebPageInfo(
url: url,
title: (result['title'] as String?)?.trim(),
favicon: favicon,
feeds: Set.from(
(result['feeds']! as List<String>).map((url) => Uri.tryParse(url)),
),
);
}, exceptionHandler: handleHttpError);
}
Future<BrowserIcon?> getCachedIcon(Uri url) async {
if (_isHttpUrl(url)) {
final cachedBrowserIcon = _browserIconCache.get(url.origin);
if (cachedBrowserIcon?.image.value != null) {
return cachedBrowserIcon;
} else if (cachedBrowserIcon != null) {
_browserIconCache.remove(url.origin);
}
final cachedIcon = await _cacheRepository.getCachedIcon(url.origin);
if (cachedIcon != null) {
return _browserIconCache.set(
url.origin,
await BrowserIcon.fromBytes(
cachedIcon,
dominantColor: null,
source: IconSource.disk,
),
);
}
}
return null;
}
Future<BrowserIcon> loadIcon({
required Uri url,
required List<Resource> resources,
bool isPrivate = false,
bool waitOnNetworkLoad = true,
}) async {
final result = await _iconsService.loadIcon(
url: url,
resources: resources,
isPrivate: isPrivate,
waitOnNetworkLoad: waitOnNetworkLoad,
);
if (result.source != IconSource.generator &&
result.source != IconSource.memory) {
await _cacheRepository.cacheIcon(url, result.image);
}
return _browserIconCache.set(
url.origin,
await BrowserIcon.fromBytes(
result.image,
dominantColor: result.color.mapNotNull((color) => Color(color)),
source: result.source,
),
);
}
Future<Result<WebPageInfo>> _deduplicatedFetchPageInfo(Uri url) {
final existing = _inFlightFetches[url];
if (existing != null) {
return existing.future;
}
late final Future<Result<WebPageInfo>> inFlightFetch;
inFlightFetch = fetchPageInfo(url: url, isImageRequest: true, proxyPort: null)
.timeout(
const Duration(seconds: 20),
onTimeout: () => Result.failure(
const ErrorMessage(source: 'icon', message: 'Icon fetch timeout'),
),
)
.whenComplete(() {
// Only clear this entry if it is still the active in-flight request.
if (identical(_inFlightFetches[url]?.future, inFlightFetch)) {
_inFlightFetches.remove(url);
}
});
_inFlightFetches[url] = _InFlightFetch(inFlightFetch);
return inFlightFetch;
}
Future<BrowserIcon?> getUrlIcon(List<Uri> urlList) async {
for (final url in urlList) {
if (!_isHttpUrl(url)) {
continue;
}
final cachedIcon = await getCachedIcon(url);
if (cachedIcon != null) {
return cachedIcon;
}
if (ref.mounted) {
final result = await _deduplicatedFetchPageInfo(url);
if (result.isSuccess) {
if (result.value.favicon case final BrowserIcon favicon) {
if (!_browserIconCache.contains(url.origin)) {
_browserIconCache.set(url.origin, favicon);
}
return favicon;
}
}
}
}
return null;
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'generic_website.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(GenericWebsiteService)
final genericWebsiteServiceProvider = GenericWebsiteServiceProvider._();
final class GenericWebsiteServiceProvider
extends $NotifierProvider<GenericWebsiteService, void> {
GenericWebsiteServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'genericWebsiteServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$genericWebsiteServiceHash();
@$internal
@override
GenericWebsiteService create() => GenericWebsiteService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$genericWebsiteServiceHash() =>
r'a2bd892f0ca07467eaa906648e57f232a7e50155';
abstract class _$GenericWebsiteService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
extension DateTimeFormat on DateTime {
String _twoDigits(int n) => n.toString().padLeft(2, '0');
String formatWithMinutePrecision() {
final year = this.year.toString();
final month = _twoDigits(this.month);
final day = _twoDigits(this.day);
final hour = _twoDigits(this.hour);
final minute = _twoDigits(this.minute);
return '$year-$month-$day $hour:$minute';
}
}
@@ -0,0 +1,28 @@
/*
* 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/>.
*/
extension DurationX on Duration {
String formatTimeZoneOffset() {
final hours = inHours.abs().toString().padLeft(2, '0');
final minutes = (inMinutes.abs() % 60).toString().padLeft(2, '0');
final sign = isNegative ? '-' : '+';
return '$sign$hours$minutes';
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:http/http.dart';
/// This is modified from default latin1 fallback to use utf8
Encoding _encodingForHeaders(Map<String, String> headers) =>
_encodingForContentTypeHeader(_contentTypeForHeaders(headers), utf8);
/// Returns the [MediaType] object for the given headers' content-type.
///
/// Defaults to `application/octet-stream`.
MediaType _contentTypeForHeaders(Map<String, String> headers) {
final contentType = headers['content-type'];
if (contentType != null) return MediaType.parse(contentType);
return MediaType('application', 'octet-stream');
}
Encoding _encodingForContentTypeHeader(
MediaType contentTypeHeader, [
Encoding fallback = latin1,
]) {
final charset = contentTypeHeader.parameters['charset'];
// Default to utf8 for application/json when charset is unspecified.
if (contentTypeHeader.type == 'application' &&
contentTypeHeader.subtype == 'json' &&
charset == null) {
return utf8;
}
// Attempt to find the encoding or fall back to the default.
return charset != null ? Encoding.getByName(charset) ?? fallback : fallback;
}
extension ResponesEncoding on Response {
String get bodyUnicodeFallback =>
_encodingForHeaders(headers).decode(bodyBytes);
}
+38
View File
@@ -0,0 +1,38 @@
/*
* 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:ui';
import 'package:fast_equatable/hash.dart';
extension ImageHash on Image {
Future<int?> calculateHash() async {
final byteData = await toByteData();
if (byteData != null) {
final digest = secureHash(byteData);
return digest;
}
return null;
}
// Future<EquatableImage> toEquatable() {
// return EquatableImage.calculate(this);
// }
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
extension UniqueItems<T> on Iterable<T> {
Iterable<T> findDuplicates() sync* {
final seen = <T>{};
for (final item in this) {
if (seen.contains(item)) {
yield item;
} else {
seen.add(item);
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
/*
* 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:ui' as ui;
import 'package:intl/locale.dart' as intl;
extension LocaleFormat on intl.Locale {
String rawToString(String separator) {
final StringBuffer out = StringBuffer(languageCode);
if (scriptCode != null && scriptCode!.isNotEmpty) {
out.write('$separator$scriptCode');
}
final String? countryCode = this.countryCode;
if (countryCode != null && countryCode.isNotEmpty) {
out.write('$separator${this.countryCode}');
}
return out.toString();
}
}
extension LocaleConverter on ui.Locale {
intl.Locale toIntlLocale() {
return intl.Locale.fromSubtags(
languageCode: languageCode,
countryCode: countryCode,
scriptCode: scriptCode,
);
}
}
@@ -0,0 +1,26 @@
/*
* 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';
extension RelativeSafeArea on MediaQueryData {
double relativeSafeArea() {
return 1.0 - (padding.top / size.height);
}
}
@@ -0,0 +1,48 @@
/*
* 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 'package:hooks_riverpod/misc.dart';
import 'package:riverpod/riverpod.dart';
extension CacheForExtension on Ref {
/// Keeps the provider alive for [duration] after the last listener is removed.
KeepAliveLink cacheFor(Duration duration) {
Timer? timer;
final link = keepAlive();
onCancel(() {
// All listeners are gone — start the dispose timer.
timer = Timer(duration, link.close);
});
onResume(() {
// A new listener was added — cancel the timer.
timer?.cancel();
});
onDispose(() {
// Provider is being fully disposed — clean up.
timer?.cancel();
});
return link;
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
* 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/>.
*/
extension StringExtension on String {
String toCapitalized() =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
bool startsWithIgnoreCase(String prefix) =>
toLowerCase().startsWith(prefix.toLowerCase());
}
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:weblibre/utils/uri_policy.dart';
extension UriX on Uri {
Uri get base => Uri.parse('$scheme://$authority');
bool get hasSupportedScheme =>
allSupportedSchemes.any((s) => s.name == scheme);
bool get isHttp => isScheme('http');
bool get isHttps => isScheme('https');
bool get isHttpOrHttps => isHttp || isHttps;
bool get isLocalhost => host == 'localhost' || host == '127.0.0.1';
/// Removes a bare root path (`/`) when there is no query or fragment, so
/// that `https://example.com/` and `https://example.com` are treated as
/// equivalent.
Uri get normalized {
if (path == '/' && !hasQuery && !hasFragment) {
return replace(path: '');
}
return this;
}
}
@@ -0,0 +1,34 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
Future<PackageInfo> packageInfo(Ref ref) async {
return await PackageInfo.fromPlatform();
}
@Riverpod(keepAlive: true)
Future<String> geckoVersion(Ref ref) async {
return await GeckoBrowserService().getGeckoVersion();
}
@@ -0,0 +1,82 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(packageInfo)
final packageInfoProvider = PackageInfoProvider._();
final class PackageInfoProvider
extends
$FunctionalProvider<
AsyncValue<PackageInfo>,
PackageInfo,
FutureOr<PackageInfo>
>
with $FutureModifier<PackageInfo>, $FutureProvider<PackageInfo> {
PackageInfoProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'packageInfoProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$packageInfoHash();
@$internal
@override
$FutureProviderElement<PackageInfo> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<PackageInfo> create(Ref ref) {
return packageInfo(ref);
}
}
String _$packageInfoHash() => r'44d37547139567a5f03c1942c1d62ff1abb07248';
@ProviderFor(geckoVersion)
final geckoVersionProvider = GeckoVersionProvider._();
final class GeckoVersionProvider
extends $FunctionalProvider<AsyncValue<String>, String, FutureOr<String>>
with $FutureModifier<String>, $FutureProvider<String> {
GeckoVersionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'geckoVersionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$geckoVersionHash();
@$internal
@override
$FutureProviderElement<String> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String> create(Ref ref) {
return geckoVersion(ref);
}
}
String _$geckoVersionHash() => r'1cdaaebc674da967a2765c5bf85a7442425c47ce';
@@ -0,0 +1,140 @@
/*
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/defaults.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/about/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
class AboutDialogScreen extends HookConsumerWidget {
const AboutDialogScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final packageInfo = ref.watch(
packageInfoProvider.select(
//During startup we make sure
(value) => value.value!,
),
);
return AboutDialog(
applicationIcon: SizedBox.square(
dimension: IconTheme.of(context).size,
child: SvgPicture.asset('assets/icon/icon.svg'),
),
applicationName: packageInfo.appName,
applicationVersion: packageInfo.version,
applicationLegalese: 'Copyright © Fabian Freund, 2024-2026',
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Gecko Version'),
subtitle: Consumer(
builder: (context, ref, child) {
final geckoVersion = ref.watch(geckoVersionProvider);
return Text(geckoVersion.value ?? 'N/A');
},
),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(MdiIcons.charity),
title: const Text('Feedback'),
onTap: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.https('feedback.weblibre.eu'),
tabMode: TabMode.regular,
selectTab: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(MdiIcons.handHeart),
title: const Text('Donate'),
onTap: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.https('github.com').replace(path: 'FaFre/WebLibre'),
tabMode: TabMode.regular,
selectTab: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
// ignore: deprecated_member_use
leading: const Icon(Icons.book),
title: const Text('Documentation'),
onTap: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: ref.read(docsUriProvider),
tabMode: TabMode.regular,
selectTab: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
// ignore: deprecated_member_use
leading: const Icon(MdiIcons.github),
title: const Text('Github'),
onTap: () async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.https('github.com').replace(path: 'FaFre/WebLibre'),
tabMode: TabMode.regular,
selectTab: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
),
],
);
}
}
@@ -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:home_widget/home_widget.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart';
part 'home_widget.g.dart';
@Riverpod(keepAlive: true)
Future<bool> widgetPinnable(Ref ref) async {
return await HomeWidget.isRequestPinWidgetSupported() ?? false;
}
@Riverpod()
Raw<Stream<ReceivedIntentParameter>> appWidgetLaunchStream(Ref ref) {
final initialStream = HomeWidget.initiallyLaunchedFromHomeWidget().asStream();
return ConcatStream([
initialStream,
HomeWidget.widgetClicked,
]).whereNotNull().map((uri) => ReceivedIntentParameter(null, uri.host));
}
@@ -0,0 +1,92 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_widget.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(widgetPinnable)
final widgetPinnableProvider = WidgetPinnableProvider._();
final class WidgetPinnableProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
WidgetPinnableProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'widgetPinnableProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$widgetPinnableHash();
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
return widgetPinnable(ref);
}
}
String _$widgetPinnableHash() => r'3181e5e3e69e7e796e6429ca7507bbbc239f6c21';
@ProviderFor(appWidgetLaunchStream)
final appWidgetLaunchStreamProvider = AppWidgetLaunchStreamProvider._();
final class AppWidgetLaunchStreamProvider
extends
$FunctionalProvider<
Raw<Stream<ReceivedIntentParameter>>,
Raw<Stream<ReceivedIntentParameter>>,
Raw<Stream<ReceivedIntentParameter>>
>
with $Provider<Raw<Stream<ReceivedIntentParameter>>> {
AppWidgetLaunchStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appWidgetLaunchStreamProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appWidgetLaunchStreamHash();
@$internal
@override
$ProviderElement<Raw<Stream<ReceivedIntentParameter>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
Raw<Stream<ReceivedIntentParameter>> create(Ref ref) {
return appWidgetLaunchStream(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Raw<Stream<ReceivedIntentParameter>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<Raw<Stream<ReceivedIntentParameter>>>(value),
);
}
}
String _$appWidgetLaunchStreamHash() =>
r'ed042d1391ae3dbc26f806d6fa2e9a4a2464ae44';
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/bangs/data/database/daos/bang.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
@DriftAccessor()
class BangDao extends DatabaseAccessor<BangDatabase> with $BangDaoMixin {
BangDao(super.db);
Selectable<Bang> getBangList({Iterable<BangGroup>? groups}) {
final selectable = select(db.bang);
if (groups != null) {
selectable.where((t) => t.group.isInValues(groups));
}
return selectable;
}
SingleSelectable<int> getBangCount({Iterable<BangGroup>? groups}) {
return db.bang.count(
where: groups.mapNotNull(
(groups) =>
(t) => t.group.isInValues(groups),
),
);
}
Future<int> upsertBang(Bang bang) {
return db.bang.insertOne(bang, mode: InsertMode.insertOrReplace);
}
SingleOrNullSelectable<BangData> getBangData(
BangGroup group,
String trigger,
) {
return select(db.bangDataView)
..where((t) => t.group.equalsValue(group) & t.trigger.equals(trigger));
}
Selectable<BangData> getBangDataList({
Iterable<String>? triggers,
Iterable<BangGroup>? groups,
String? domain,
String? category,
String? subCategory,
bool? orderMostFrequentFirst,
}) {
final selectable = select(db.bangDataView);
if (triggers != null) {
selectable.where((t) => t.trigger.isIn(triggers));
}
if (groups != null) {
selectable.where((t) => t.group.isInValues(groups));
}
if (domain != null) {
selectable.where((t) => t.domain.equals(domain));
}
if (category != null) {
selectable.where((t) => t.category.equals(category));
if (subCategory != null) {
selectable.where((t) => t.subCategory.equals(subCategory));
}
}
selectable.orderBy([
if (orderMostFrequentFirst == true) (t) => OrderingTerm.desc(t.frequency),
(t) => OrderingTerm.asc(t.websiteName),
]);
return selectable;
}
Selectable<BangData> getFrequentBangDataList({Iterable<BangGroup>? groups}) {
final selectable = select(db.bangDataView)
..where((t) => t.frequency.isBiggerThanValue(0));
if (groups != null) {
selectable.where((t) => t.group.isInValues(groups));
}
selectable.orderBy([
(t) => OrderingTerm.desc(t.frequency),
(t) => OrderingTerm.desc(t.lastUsed),
]);
return selectable;
}
Future<int> increaseBangFrequency(BangKey key) {
return db.bangFrequency.insertOne(
BangFrequencyCompanion.insert(
trigger: key.trigger,
group: key.group,
frequency: 1,
lastUsed: DateTime.now(),
),
onConflict: DoUpdate(
(old) => BangFrequencyCompanion.custom(
frequency: old.frequency + const Constant(1),
lastUsed: Variable(DateTime.now()),
),
),
);
}
Selectable<BangData> queryBangs(String searchString) {
final ftsQuery = db.buildFtsQuery(searchString);
if (ftsQuery.isNotEmpty) {
return db.definitionsDrift.queryBangs(query: ftsQuery);
} else {
return db.definitionsDrift.queryBangsBasic(
query: db.buildLikeQuery(searchString),
);
}
}
Future<int> addSearchEntry(
BangGroup group,
String trigger,
String searchQuery,
) {
return db.bangHistory.insertOne(
BangHistoryCompanion.insert(
searchQuery: searchQuery,
trigger: trigger,
group: group,
searchDate: DateTime.now(),
),
onConflict: DoUpdate(
target: [db.bangHistory.searchQuery],
(old) => BangHistoryCompanion(
trigger: Value(trigger),
group: Value(group),
searchDate: Value(DateTime.now()),
),
),
);
}
Future<int> removeSearchEntry(String searchQuery) {
return db.bangHistory.deleteWhere((t) => t.searchQuery.equals(searchQuery));
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/database.dart' as i1;
mixin $BangDaoMixin on i0.DatabaseAccessor<i1.BangDatabase> {
BangDaoManager get managers => BangDaoManager(this);
}
class BangDaoManager {
final $BangDaoMixin _db;
BangDaoManager(this._db);
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/features/bangs/data/database/daos/sync.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
@DriftAccessor()
class SyncDao extends DatabaseAccessor<BangDatabase> with $SyncDaoMixin {
SyncDao(super.db);
SingleOrNullSelectable<DateTime?> getLastSyncOfGroup(BangGroup group) {
final query = selectOnly(db.bangSync)
..addColumns([db.bangSync.lastSync])
..where(db.bangSync.group.equalsValue(group));
return query.map((row) => row.read(db.bangSync.lastSync));
}
Future<void> upsertLastSyncOfGroup(BangGroup group, DateTime lastSync) {
return db.bangSync.insertOne(
BangSyncCompanion.insert(group: Value(group), lastSync: lastSync),
onConflict: DoUpdate(
(old) => BangSyncCompanion(lastSync: Value(lastSync)),
),
);
}
Future<void> insertBangs(Iterable<Bang> bangs) {
return db.bang.insertAll(bangs);
}
Future<void> replaceBangs(Iterable<Bang> bangs) {
return batch((batch) {
batch.replaceAll(db.bang, bangs);
});
}
Future<int> deleteBangs(BangGroup group, Iterable<String> triggers) {
final statement = delete(db.bang)
..where((t) => t.group.equalsValue(group) & t.trigger.isIn(triggers));
return statement.go();
}
Future<void> syncBangs({
required BangGroup group,
required Iterable<Bang> remoteBangs,
required DateTime syncTime,
}) async {
final remoteBangMap = Map.fromEntries(
remoteBangs.map((e) => MapEntry(e.trigger, e)),
);
final localBangMap = await db.bangDao
.getBangList(groups: [group])
.get()
.then(
(bangs) => Map.fromEntries(bangs.map((e) => MapEntry(e.trigger, e))),
);
final remoteBangTriggers = remoteBangMap.keys.toSet();
final localBangTriggers = localBangMap.keys.toSet();
final removedBangs = localBangTriggers.difference(remoteBangTriggers);
final addedBangs = remoteBangTriggers
.difference(localBangTriggers)
.map((e) => remoteBangMap[e]!);
final changedBangs = remoteBangTriggers
.intersection(localBangTriggers)
.where((e) => remoteBangMap[e] != localBangMap[e])
.map((e) => remoteBangMap[e]!);
await db.transaction(() async {
await deleteBangs(group, removedBangs);
await insertBangs(addedBangs);
await replaceBangs(changedBangs);
await upsertLastSyncOfGroup(group, syncTime);
});
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/database.dart' as i1;
mixin $SyncDaoMixin on i0.DatabaseAccessor<i1.BangDatabase> {
SyncDaoManager get managers => SyncDaoManager(this);
}
class SyncDaoManager {
final $SyncDaoMixin _db;
SyncDaoManager(this._db);
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:weblibre/features/bangs/data/database/daos/bang.dart';
import 'package:weblibre/features/bangs/data/database/daos/sync.dart';
import 'package:weblibre/features/bangs/data/database/database.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.steps.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
@DriftDatabase(include: {'definitions.drift'}, daos: [BangDao, SyncDao])
class BangDatabase extends $BangDatabase with PrefixQueryBuilderMixin {
@override
final int schemaVersion = 5;
@override
final int ftsTokenLimit = 6;
@override
final int ftsMinTokenLength = 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips
await customStatement('PRAGMA foreign_keys = OFF');
await transaction(
() => VersionedSchema.runMigrationSteps(
migrator: m,
from: from,
to: to,
steps: _upgrade,
),
);
if (kDebugMode) {
final wrongForeignKeys = await customSelect(
'PRAGMA foreign_key_check',
).get();
assert(
wrongForeignKeys.isEmpty,
'${wrongForeignKeys.map((e) => e.data)}',
);
}
await customStatement('PRAGMA foreign_keys = ON');
},
beforeOpen: (details) async {
if (kDebugMode) {
// This check pulls in a fair amount of code that's not needed
// anywhere else, so we recommend only doing it in debug builds.
await validateDatabaseSchema();
}
if (details.hadUpgrade && details.versionBefore != null) {
await customStatement('PRAGMA foreign_keys = OFF');
if (details.versionBefore! < 3) {
await bang.deleteWhere((t) => t.group.equals(3));
await bangTriggers.deleteWhere((t) => t.group.equals(3));
await bangSync.deleteWhere((t) => t.group.equals(3));
await bangFrequency.deleteWhere((t) => t.group.equals(3));
await bangHistory.deleteWhere((t) => t.group.equals(3));
} else if (details.versionBefore! < 5) {
await bang.deleteWhere((t) => t.group.equals(1));
await bangTriggers.deleteWhere((t) => t.group.equals(1));
await bangSync.deleteWhere((t) => t.group.equals(1));
await bangFrequency.deleteWhere((t) => t.group.equals(1));
await bangHistory.deleteWhere((t) => t.group.equals(1));
await (bang.update()..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangCompanion.custom(group: bang.group - const Constant(1)),
);
await (bangTriggers.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangTriggersCompanion.custom(
group: bangTriggers.group - const Constant(1),
),
);
await (bangSync.update()..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangSyncCompanion.custom(
group: bangSync.group - const Constant(1),
),
);
await (bangFrequency.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangFrequencyCompanion.custom(
group: bangFrequency.group - const Constant(1),
),
);
await (bangHistory.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangHistoryCompanion.custom(
group: bangHistory.group - const Constant(1),
),
);
}
}
await customStatement('PRAGMA foreign_keys = ON');
},
);
BangDatabase(super.e);
static final _upgrade = migrationSteps(
from1To2: (m, schema) async {
//Too many changes, we switch to a new database
},
from2To3: (m, schema) async {
await m.addColumn(schema.bang, schema.bang.searxngApi);
},
from3To4: (m, schema) async {
await m.alterTable(TableMigration(schema.bangHistory));
},
from4To5: (m, schema) async {
await m.addColumn(schema.bang, schema.bang.snapDomain);
},
);
}
@@ -0,0 +1,200 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart'
as i1;
import 'package:weblibre/features/bangs/data/database/daos/bang.dart' as i2;
import 'package:weblibre/features/bangs/data/database/database.dart' as i3;
import 'package:weblibre/features/bangs/data/database/daos/sync.dart' as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:sqlite3/common.dart' as i6;
abstract class $BangDatabase extends i0.GeneratedDatabase {
$BangDatabase(i0.QueryExecutor e) : super(e);
$BangDatabaseManager get managers => $BangDatabaseManager(this);
late final i1.BangTable bang = i1.BangTable(this);
late final i1.BangTriggers bangTriggers = i1.BangTriggers(this);
late final i1.BangSync bangSync = i1.BangSync(this);
late final i1.BangFrequency bangFrequency = i1.BangFrequency(this);
late final i1.BangHistory bangHistory = i1.BangHistory(this);
late final i1.BangFts bangFts = i1.BangFts(this);
late final i1.BangTriggersFts bangTriggersFts = i1.BangTriggersFts(this);
late final i1.BangDataView bangDataView = i1.BangDataView(this);
late final i2.BangDao bangDao = i2.BangDao(this as i3.BangDatabase);
late final i4.SyncDao syncDao = i4.SyncDao(this as i3.BangDatabase);
i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer(
this,
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@override
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
bang,
bangTriggers,
i1.idxBangTriggersLookup,
i1.bangTriggersAfterInsert,
i1.bangTriggersAfterUpdate,
bangSync,
bangFrequency,
bangHistory,
bangFts,
bangTriggersFts,
bangDataView,
i1.bangAfterInsert,
i1.bangAfterDelete,
i1.bangAfterUpdate,
i1.bangTriggersAfterInsertFts,
i1.bangTriggersAfterDeleteFts,
i1.bangTriggersAfterUpdateFts,
];
@override
i0.StreamQueryUpdateRules
get streamUpdateRules => const i0.StreamQueryUpdateRules([
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.update,
),
result: [
i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.delete),
i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.insert),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_frequency', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_history', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.update,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.update,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
]);
}
class $BangDatabaseManager {
final $BangDatabase _db;
$BangDatabaseManager(this._db);
i1.$BangTableTableManager get bang =>
i1.$BangTableTableManager(_db, _db.bang);
i1.$BangTriggersTableManager get bangTriggers =>
i1.$BangTriggersTableManager(_db, _db.bangTriggers);
i1.$BangSyncTableManager get bangSync =>
i1.$BangSyncTableManager(_db, _db.bangSync);
i1.$BangFrequencyTableManager get bangFrequency =>
i1.$BangFrequencyTableManager(_db, _db.bangFrequency);
i1.$BangHistoryTableManager get bangHistory =>
i1.$BangHistoryTableManager(_db, _db.bangHistory);
i1.$BangFtsTableManager get bangFts =>
i1.$BangFtsTableManager(_db, _db.bangFts);
i1.$BangTriggersFtsTableManager get bangTriggersFts =>
i1.$BangTriggersFtsTableManager(_db, _db.bangTriggersFts);
}
extension DefineFunctions on i6.CommonDatabase {
void defineFunctions({
required String Function(int, String?) lexoRankNext,
required String Function(int, String?) lexoRankPrevious,
required String Function(String?, String?) lexoRankReorderAfter,
required String Function(String?, String?) lexoRankReorderBefore,
}) {
createFunction(
functionName: 'lexo_rank_next',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankNext(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_previous',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankPrevious(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_after',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderAfter(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_before',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderBefore(arg0, arg1);
},
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
import 'package:weblibre/features/bangs/data/database/drift/converters/bang_format.dart';
import 'package:weblibre/features/bangs/data/database/drift/converters/trigger_list.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
CREATE TABLE bang (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
website_name TEXT NOT NULL,
domain TEXT NOT NULL,
url_template TEXT NOT NULL,
category TEXT,
sub_category TEXT,
format TEXT MAPPED BY `const BangFormatConverter()`,
additional_triggers TEXT MAPPED BY `const TriggerListConverter()`,
searxng_api BOOL NOT NULL DEFAULT FALSE,
snap_domain TEXT,
PRIMARY KEY ("trigger", "group")
) WITH Bang;
CREATE TABLE bang_triggers (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
additional_trigger TEXT NOT NULL,
PRIMARY KEY ("trigger", "group", additional_trigger),
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE INDEX idx_bang_triggers_lookup ON bang_triggers (additional_trigger, "group");
-- Trigger to populate bang_triggers when inserting a new bang
CREATE TRIGGER bang_triggers_after_insert AFTER INSERT ON bang
WHEN new.additional_triggers IS NOT NULL
BEGIN
INSERT INTO bang_triggers("trigger", "group", additional_trigger)
SELECT
new."trigger",
new."group",
json_each.value
FROM json_each(new.additional_triggers);
END;
-- Trigger to update bang_triggers when updating a bang
CREATE TRIGGER bang_triggers_after_update AFTER UPDATE ON bang BEGIN
-- Delete old additional triggers
DELETE FROM bang_triggers
WHERE "trigger" = old."trigger" AND "group" = old."group";
-- Insert new additional triggers if they exist
INSERT INTO bang_triggers("trigger", "group", additional_trigger)
SELECT
new."trigger",
new."group",
json_each.value
FROM json_each(new.additional_triggers)
WHERE new.additional_triggers IS NOT NULL;
END;
CREATE TABLE bang_sync (
"group" ENUM(BangGroup) PRIMARY KEY NOT NULL,
last_sync DATETIME NOT NULL
);
CREATE TABLE bang_frequency (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
frequency INTEGER NOT NULL,
last_used DATETIME NOT NULL,
PRIMARY KEY ("trigger", "group"),
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE TABLE bang_history (
search_query TEXT UNIQUE NOT NULL,
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
search_date DATETIME NOT NULL,
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE VIRTUAL TABLE bang_fts
USING fts5(
trigger,
website_name,
content=bang,
prefix='2 3'
);
CREATE VIRTUAL TABLE bang_triggers_fts
USING fts5(
additional_trigger,
content=bang_triggers,
prefix='2 3'
);
CREATE VIEW bang_data_view WITH BangData AS
SELECT
b.*,
bf.frequency,
bf.last_used
FROM
bang b
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group";
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN
INSERT INTO
bang_fts(rowid, "trigger", website_name)
VALUES (new.rowid, new."trigger", new.website_name);
END;
CREATE TRIGGER bang_after_delete AFTER DELETE ON bang BEGIN
INSERT INTO
bang_fts(bang_fts, rowid, "trigger", website_name)
VALUES('delete', old.rowid, old."trigger", old.website_name);
END;
CREATE TRIGGER bang_after_update AFTER UPDATE ON bang BEGIN
INSERT INTO
bang_fts(bang_fts, rowid, "trigger", website_name)
VALUES('delete', old.rowid, old."trigger", old.website_name);
INSERT INTO
bang_fts(rowid, "trigger", website_name)
VALUES (new.rowid, new."trigger", new.website_name);
END;
CREATE TRIGGER bang_triggers_after_insert_fts AFTER INSERT ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(rowid, additional_trigger)
VALUES (new.rowid, new.additional_trigger);
END;
CREATE TRIGGER bang_triggers_after_delete_fts AFTER DELETE ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(bang_triggers_fts, rowid, additional_trigger)
VALUES('delete', old.rowid, old.additional_trigger);
END;
CREATE TRIGGER bang_triggers_after_update_fts AFTER UPDATE ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(bang_triggers_fts, rowid, additional_trigger)
VALUES('delete', old.rowid, old.additional_trigger);
INSERT INTO
bang_triggers_fts(rowid, additional_trigger)
VALUES (new.rowid, new.additional_trigger);
END;
optimizeBangFtsIndex:
INSERT INTO bang_fts(bang_fts) VALUES ('optimize');
optimizeTriggerFtsIndex:
INSERT INTO bang_triggers_fts(bang_triggers_fts) VALUES ('optimize');
queryBangs WITH BangData:
WITH weights AS (
SELECT
10.0 AS "trigger",
8.0 AS additional_trigger,
5.0 AS website_name
),
bang_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts(:query) fts
INNER JOIN
bang b ON b.rowid = fts.rowid
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
),
trigger_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_triggers_fts, weights.additional_trigger) AS weighted_rank
FROM
bang_triggers_fts(:query) tfts
INNER JOIN
bang_triggers bt ON bt.rowid = tfts.rowid
INNER JOIN
bang b ON b."trigger" = bt."trigger" AND b."group" = bt."group"
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
),
combined_results AS (
SELECT * FROM bang_results
UNION ALL
SELECT * FROM trigger_results
)
SELECT
*,
MIN(weighted_rank) AS weighted_rank
FROM combined_results
GROUP BY "trigger", "group"
ORDER BY
weighted_rank ASC,
frequency NULLS LAST;
queryBangsBasic WITH BangData:
WITH weights AS (
SELECT
10.0 AS "trigger",
8.0 AS additional_trigger,
5.0 AS website_name
),
bang_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts fts
INNER JOIN
bang b ON b.rowid = fts.rowid
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
WHERE
fts."trigger" LIKE :query OR
fts.website_name LIKE :query
),
trigger_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_triggers_fts, weights.additional_trigger) AS weighted_rank
FROM
bang_triggers_fts tfts
INNER JOIN
bang_triggers bt ON bt.rowid = tfts.rowid
INNER JOIN
bang b ON b."trigger" = bt."trigger" AND b."group" = bt."group"
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
WHERE
tfts.additional_trigger LIKE :query
),
combined_results AS (
SELECT * FROM bang_results
UNION ALL
SELECT * FROM trigger_results
)
SELECT
*,
MIN(weighted_rank) AS weighted_rank
FROM combined_results
GROUP BY "trigger", "group"
ORDER BY
weighted_rank ASC,
frequency NULLS LAST;
categoriesJson:
WITH categories AS (
SELECT
b.category,
json_group_array(
DISTINCT b.sub_category
ORDER BY b.sub_category
) AS sub_categories
FROM
bang b
WHERE
b.category IS NOT NULL AND
b.sub_category IS NOT NULL
GROUP BY b.category
ORDER BY b.category
)
SELECT
json_group_object(
c.category,
json(c.sub_categories)
) AS categories_json
FROM categories c;
searchHistoryEntries WITH SearchHistoryEntry:
SELECT *
FROM bang_history
ORDER BY search_date DESC
LIMIT :limit;
evictHistoryEntries:
DELETE FROM bang_history
WHERE rowid IN (
SELECT rowid
FROM bang_history
ORDER BY search_date DESC
LIMIT -1 OFFSET :limit
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
class BangFormatConverter extends TypeConverter<Set<BangFormat>?, String?> {
const BangFormatConverter();
@override
Set<BangFormat>? fromSql(String? fromDb) {
if (fromDb == null) {
return null;
}
return Bang.decodeFormat(jsonDecode(fromDb) as List);
}
@override
String? toSql(Set<BangFormat>? value) {
if (value == null) {
return null;
}
return jsonEncode(Bang.encodeFormat(value));
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:nullability/nullability.dart';
class TriggerListConverter extends TypeConverter<Set<String>?, String?> {
const TriggerListConverter();
@override
Set<String>? fromSql(String? fromDb) {
return fromDb.mapNotNull(
(value) => (jsonDecode(value) as List).cast<String>().toSet(),
);
}
@override
String? toSql(Set<String>? value) {
return value.mapNotNull((value) => jsonEncode(value.toList()));
}
}
@@ -0,0 +1,196 @@
/*
* 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:copy_with_extension/copy_with_extension.dart';
import 'package:drift/drift.dart' show Expression, Insertable, Value;
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
part 'bang.g.dart';
enum BangFormat {
///When the bang is invoked with no query, opens the base path of the URL (/)
///instead of any path given in the template (g., /search)
@JsonValue('open_base_path')
openBasePath,
///URL encode the search terms. Some sites do not work with this, so it can
///be disabled by omitting this.
@JsonValue('url_encode_placeholder')
urlEncodePlaceholder,
///URL encodes spaces as +, instead of %20. Some sites only work correctly
///with one or the other.
@JsonValue('url_encode_space_to_plus')
urlEncodeSpaceToPlus,
///When the bang is invoked with no query, open the snap domain (ad) instead of any path given in the template
@JsonValue('open_snap_domain')
openSnapDomain,
}
@JsonSerializable()
@CopyWith()
class Bang with FastEquatable implements Insertable<Bang> {
static const _templateQueryPlaceholder = '{{{s}}}';
@JsonKey(includeFromJson: false, includeToJson: false)
final BangGroup? group;
///The name of the website associated with the bang.
@JsonKey(name: 's')
final String websiteName;
///The domain name of the websit
@JsonKey(name: 'd')
final String domain;
///The specific trigger word or phrase used to invoke the bang.
@JsonKey(name: 't')
final String trigger;
///The URL template to use when the bang is invoked, where `{{{s}}}` is replaced by the user's query.
@JsonKey(name: 'u')
final String urlTemplate;
///The category of the website, if applicable
@JsonKey(name: 'c')
final String? category;
///The subcategory of the website, if applicable
@JsonKey(name: 'sc')
final String? subCategory;
///The format flags indicating how the query should be processed.
@JsonKey(name: 'fmt')
final Set<BangFormat>? format;
///Additional triggers that invoke this bang
@JsonKey(name: 'ts')
final Set<String>? additionalTriggers;
///Additional triggers that invoke this bang
@JsonKey(name: 'ad')
final String? snapDomain;
@JsonKey(defaultValue: false)
final bool searxngApi;
String formatQuery(String input) {
return (format == null ||
format!.contains(BangFormat.urlEncodePlaceholder) == true)
? (format == null ||
format?.contains(BangFormat.urlEncodeSpaceToPlus) == true)
? Uri.encodeQueryComponent(input)
: Uri.encodeComponent(input)
: input;
}
Uri getDefaultUrl() {
return getTemplateUrl('');
}
Uri getTemplateUrl(String? query) {
final queryEmpty = query.isEmpty;
if (queryEmpty && format?.contains(BangFormat.openSnapDomain) == true) {
if (snapDomain.isNotEmpty) {
return Uri.parse(snapDomain!);
}
}
final url = (!queryEmpty)
? urlTemplate.replaceAll(_templateQueryPlaceholder, formatQuery(query!))
: urlTemplate;
var template = Uri.parse(url);
if (!template.hasScheme || template.origin.isEmpty) {
template = Uri.https(
domain,
).replace(path: template.path, query: template.query);
}
if (queryEmpty && format?.contains(BangFormat.openBasePath) == true) {
template = template.base;
}
return template;
}
static Set<BangFormat> decodeFormat(Iterable input) {
return input.map((e) => $enumDecode(_$BangFormatEnumMap, e)).toSet();
}
static List<String> encodeFormat(Iterable<BangFormat> format) {
return format.map((e) => _$BangFormatEnumMap[e]!).toList();
}
Bang({
required this.websiteName,
required this.domain,
required this.trigger,
required this.urlTemplate,
required this.searxngApi,
this.group,
this.category,
this.subCategory,
this.format,
this.additionalTriggers,
this.snapDomain,
});
factory Bang.fromJson(Map<String, dynamic> json) => _$BangFromJson(json);
Map<String, dynamic> toJson() => _$BangToJson(this);
@override
List<Object?> get hashParameters => [
group,
websiteName,
domain,
trigger,
urlTemplate,
category,
subCategory,
format,
additionalTriggers,
snapDomain,
searxngApi,
];
@override
Map<String, Expression<Object>> toColumns(bool nullToAbsent) {
return BangCompanion(
trigger: Value(trigger),
websiteName: Value(websiteName),
domain: Value(domain),
urlTemplate: Value(urlTemplate),
group: Value.absentIfNull(group),
category: Value.absentIfNull(category),
subCategory: Value.absentIfNull(subCategory),
format: Value.absentIfNull(format),
additionalTriggers: Value.absentIfNull(additionalTriggers),
snapDomain: Value.absentIfNull(snapDomain),
).toColumns(nullToAbsent);
}
}
@@ -0,0 +1,214 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bang.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$BangCWProxy {
Bang websiteName(String websiteName);
Bang domain(String domain);
Bang trigger(String trigger);
Bang urlTemplate(String urlTemplate);
Bang searxngApi(bool searxngApi);
Bang group(BangGroup? group);
Bang category(String? category);
Bang subCategory(String? subCategory);
Bang format(Set<BangFormat>? format);
Bang additionalTriggers(Set<String>? additionalTriggers);
Bang snapDomain(String? snapDomain);
/// 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 `Bang(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// Bang(...).copyWith(id: 12, name: "My name")
/// ```
Bang call({
String websiteName,
String domain,
String trigger,
String urlTemplate,
bool searxngApi,
BangGroup? group,
String? category,
String? subCategory,
Set<BangFormat>? format,
Set<String>? additionalTriggers,
String? snapDomain,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBang.copyWith(...)` or call `instanceOfBang.copyWith.fieldName(value)` for a single field.
class _$BangCWProxyImpl implements _$BangCWProxy {
const _$BangCWProxyImpl(this._value);
final Bang _value;
@override
Bang websiteName(String websiteName) => call(websiteName: websiteName);
@override
Bang domain(String domain) => call(domain: domain);
@override
Bang trigger(String trigger) => call(trigger: trigger);
@override
Bang urlTemplate(String urlTemplate) => call(urlTemplate: urlTemplate);
@override
Bang searxngApi(bool searxngApi) => call(searxngApi: searxngApi);
@override
Bang group(BangGroup? group) => call(group: group);
@override
Bang category(String? category) => call(category: category);
@override
Bang subCategory(String? subCategory) => call(subCategory: subCategory);
@override
Bang format(Set<BangFormat>? format) => call(format: format);
@override
Bang additionalTriggers(Set<String>? additionalTriggers) =>
call(additionalTriggers: additionalTriggers);
@override
Bang snapDomain(String? snapDomain) => call(snapDomain: snapDomain);
@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 `Bang(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// Bang(...).copyWith(id: 12, name: "My name")
/// ```
Bang call({
Object? websiteName = const $CopyWithPlaceholder(),
Object? domain = const $CopyWithPlaceholder(),
Object? trigger = const $CopyWithPlaceholder(),
Object? urlTemplate = const $CopyWithPlaceholder(),
Object? searxngApi = const $CopyWithPlaceholder(),
Object? group = const $CopyWithPlaceholder(),
Object? category = const $CopyWithPlaceholder(),
Object? subCategory = const $CopyWithPlaceholder(),
Object? format = const $CopyWithPlaceholder(),
Object? additionalTriggers = const $CopyWithPlaceholder(),
Object? snapDomain = const $CopyWithPlaceholder(),
}) {
return Bang(
websiteName:
websiteName == const $CopyWithPlaceholder() || websiteName == null
? _value.websiteName
// ignore: cast_nullable_to_non_nullable
: websiteName as String,
domain: domain == const $CopyWithPlaceholder() || domain == null
? _value.domain
// ignore: cast_nullable_to_non_nullable
: domain as String,
trigger: trigger == const $CopyWithPlaceholder() || trigger == null
? _value.trigger
// ignore: cast_nullable_to_non_nullable
: trigger as String,
urlTemplate:
urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null
? _value.urlTemplate
// ignore: cast_nullable_to_non_nullable
: urlTemplate as String,
searxngApi:
searxngApi == const $CopyWithPlaceholder() || searxngApi == null
? _value.searxngApi
// ignore: cast_nullable_to_non_nullable
: searxngApi as bool,
group: group == const $CopyWithPlaceholder()
? _value.group
// ignore: cast_nullable_to_non_nullable
: group as BangGroup?,
category: category == const $CopyWithPlaceholder()
? _value.category
// ignore: cast_nullable_to_non_nullable
: category as String?,
subCategory: subCategory == const $CopyWithPlaceholder()
? _value.subCategory
// ignore: cast_nullable_to_non_nullable
: subCategory as String?,
format: format == const $CopyWithPlaceholder()
? _value.format
// ignore: cast_nullable_to_non_nullable
: format as Set<BangFormat>?,
additionalTriggers: additionalTriggers == const $CopyWithPlaceholder()
? _value.additionalTriggers
// ignore: cast_nullable_to_non_nullable
: additionalTriggers as Set<String>?,
snapDomain: snapDomain == const $CopyWithPlaceholder()
? _value.snapDomain
// ignore: cast_nullable_to_non_nullable
: snapDomain as String?,
);
}
}
extension $BangCopyWith on Bang {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBang.copyWith(...)` or `instanceOfBang.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BangCWProxy get copyWith => _$BangCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Bang _$BangFromJson(Map<String, dynamic> json) => Bang(
websiteName: json['s'] as String,
domain: json['d'] as String,
trigger: json['t'] as String,
urlTemplate: json['u'] as String,
searxngApi: json['searxngApi'] as bool? ?? false,
category: json['c'] as String?,
subCategory: json['sc'] as String?,
format: (json['fmt'] as List<dynamic>?)
?.map((e) => $enumDecode(_$BangFormatEnumMap, e))
.toSet(),
additionalTriggers: (json['ts'] as List<dynamic>?)
?.map((e) => e as String)
.toSet(),
snapDomain: json['ad'] as String?,
);
Map<String, dynamic> _$BangToJson(Bang instance) => <String, dynamic>{
's': instance.websiteName,
'd': instance.domain,
't': instance.trigger,
'u': instance.urlTemplate,
'c': instance.category,
'sc': instance.subCategory,
'fmt': instance.format?.map((e) => _$BangFormatEnumMap[e]!).toList(),
'ts': instance.additionalTriggers?.toList(),
'ad': instance.snapDomain,
'searxngApi': instance.searxngApi,
};
const _$BangFormatEnumMap = {
BangFormat.openBasePath: 'open_base_path',
BangFormat.urlEncodePlaceholder: 'url_encode_placeholder',
BangFormat.urlEncodeSpaceToPlus: 'url_encode_space_to_plus',
BangFormat.openSnapDomain: 'open_snap_domain',
};
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
part 'bang_data.g.dart';
@CopyWith(constructor: '_copyWith')
class BangData extends Bang {
final int frequency;
final DateTime? lastUsed;
final BrowserIcon? icon;
@override
BangGroup get group => super.group!;
BangData({
required super.websiteName,
required super.domain,
required super.trigger,
required super.urlTemplate,
required super.group,
required super.searxngApi,
super.category,
super.subCategory,
super.format,
super.additionalTriggers,
super.snapDomain,
int? frequency,
this.lastUsed,
this.icon,
}) : frequency = frequency ?? 0;
//For some reasons including super.group breaks generation of copywith, so we have this one for now
BangData._copyWith({
required super.websiteName,
required super.domain,
required super.trigger,
required super.urlTemplate,
required super.searxngApi,
super.category,
super.subCategory,
super.format,
super.additionalTriggers,
super.snapDomain,
int? frequency,
this.lastUsed,
this.icon,
}) : frequency = frequency ?? 0;
BangKey toKey() => BangKey(group: group, trigger: trigger);
@override
List<Object?> get hashParameters => [
...super.hashParameters,
frequency,
lastUsed,
icon,
];
}
@@ -0,0 +1,195 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bang_data.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$BangDataCWProxy {
BangData websiteName(String websiteName);
BangData domain(String domain);
BangData trigger(String trigger);
BangData urlTemplate(String urlTemplate);
BangData searxngApi(bool searxngApi);
BangData category(String? category);
BangData subCategory(String? subCategory);
BangData format(Set<BangFormat>? format);
BangData additionalTriggers(Set<String>? additionalTriggers);
BangData snapDomain(String? snapDomain);
BangData frequency(int? frequency);
BangData lastUsed(DateTime? lastUsed);
BangData icon(BrowserIcon? icon);
/// 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 `BangData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BangData(...).copyWith(id: 12, name: "My name")
/// ```
BangData call({
String websiteName,
String domain,
String trigger,
String urlTemplate,
bool searxngApi,
String? category,
String? subCategory,
Set<BangFormat>? format,
Set<String>? additionalTriggers,
String? snapDomain,
int? frequency,
DateTime? lastUsed,
BrowserIcon? icon,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBangData.copyWith(...)` or call `instanceOfBangData.copyWith.fieldName(value)` for a single field.
class _$BangDataCWProxyImpl implements _$BangDataCWProxy {
const _$BangDataCWProxyImpl(this._value);
final BangData _value;
@override
BangData websiteName(String websiteName) => call(websiteName: websiteName);
@override
BangData domain(String domain) => call(domain: domain);
@override
BangData trigger(String trigger) => call(trigger: trigger);
@override
BangData urlTemplate(String urlTemplate) => call(urlTemplate: urlTemplate);
@override
BangData searxngApi(bool searxngApi) => call(searxngApi: searxngApi);
@override
BangData category(String? category) => call(category: category);
@override
BangData subCategory(String? subCategory) => call(subCategory: subCategory);
@override
BangData format(Set<BangFormat>? format) => call(format: format);
@override
BangData additionalTriggers(Set<String>? additionalTriggers) =>
call(additionalTriggers: additionalTriggers);
@override
BangData snapDomain(String? snapDomain) => call(snapDomain: snapDomain);
@override
BangData frequency(int? frequency) => call(frequency: frequency);
@override
BangData lastUsed(DateTime? lastUsed) => call(lastUsed: lastUsed);
@override
BangData icon(BrowserIcon? icon) => call(icon: icon);
@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 `BangData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BangData(...).copyWith(id: 12, name: "My name")
/// ```
BangData call({
Object? websiteName = const $CopyWithPlaceholder(),
Object? domain = const $CopyWithPlaceholder(),
Object? trigger = const $CopyWithPlaceholder(),
Object? urlTemplate = const $CopyWithPlaceholder(),
Object? searxngApi = const $CopyWithPlaceholder(),
Object? category = const $CopyWithPlaceholder(),
Object? subCategory = const $CopyWithPlaceholder(),
Object? format = const $CopyWithPlaceholder(),
Object? additionalTriggers = const $CopyWithPlaceholder(),
Object? snapDomain = const $CopyWithPlaceholder(),
Object? frequency = const $CopyWithPlaceholder(),
Object? lastUsed = const $CopyWithPlaceholder(),
Object? icon = const $CopyWithPlaceholder(),
}) {
return BangData._copyWith(
websiteName:
websiteName == const $CopyWithPlaceholder() || websiteName == null
? _value.websiteName
// ignore: cast_nullable_to_non_nullable
: websiteName as String,
domain: domain == const $CopyWithPlaceholder() || domain == null
? _value.domain
// ignore: cast_nullable_to_non_nullable
: domain as String,
trigger: trigger == const $CopyWithPlaceholder() || trigger == null
? _value.trigger
// ignore: cast_nullable_to_non_nullable
: trigger as String,
urlTemplate:
urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null
? _value.urlTemplate
// ignore: cast_nullable_to_non_nullable
: urlTemplate as String,
searxngApi:
searxngApi == const $CopyWithPlaceholder() || searxngApi == null
? _value.searxngApi
// ignore: cast_nullable_to_non_nullable
: searxngApi as bool,
category: category == const $CopyWithPlaceholder()
? _value.category
// ignore: cast_nullable_to_non_nullable
: category as String?,
subCategory: subCategory == const $CopyWithPlaceholder()
? _value.subCategory
// ignore: cast_nullable_to_non_nullable
: subCategory as String?,
format: format == const $CopyWithPlaceholder()
? _value.format
// ignore: cast_nullable_to_non_nullable
: format as Set<BangFormat>?,
additionalTriggers: additionalTriggers == const $CopyWithPlaceholder()
? _value.additionalTriggers
// ignore: cast_nullable_to_non_nullable
: additionalTriggers as Set<String>?,
snapDomain: snapDomain == const $CopyWithPlaceholder()
? _value.snapDomain
// ignore: cast_nullable_to_non_nullable
: snapDomain as String?,
frequency: frequency == const $CopyWithPlaceholder()
? _value.frequency
// ignore: cast_nullable_to_non_nullable
: frequency as int?,
lastUsed: lastUsed == const $CopyWithPlaceholder()
? _value.lastUsed
// ignore: cast_nullable_to_non_nullable
: lastUsed as DateTime?,
icon: icon == const $CopyWithPlaceholder()
? _value.icon
// ignore: cast_nullable_to_non_nullable
: icon as BrowserIcon?,
);
}
}
extension $BangDataCopyWith on BangData {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBangData.copyWith(...)` or `instanceOfBangData.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BangDataCWProxy get copyWith => _$BangDataCWProxyImpl(this);
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
enum BangGroup {
general(
remote:
'https://raw.githubusercontent.com/FaFre/bangs/main/data/bangs.json',
bundled: 'assets/bangs/bangs.json',
),
kagi(
remote:
'https://raw.githubusercontent.com/FaFre/bangs/main/data/kagi_bangs.json',
bundled: 'assets/bangs/kagi_bangs.json',
),
user(remote: null, bundled: null);
final String? bundled;
final String? remote;
const BangGroup({required this.bundled, required this.remote});
}
@@ -0,0 +1,83 @@
/*
* 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:json_annotation/json_annotation.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
class BangKey {
final String trigger;
final BangGroup group;
const BangKey({required this.group, required this.trigger});
@override
String toString() {
return '${group.name}::$trigger';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BangKey &&
runtimeType == other.runtimeType &&
trigger == other.trigger &&
group == other.group;
@override
int get hashCode => Object.hash(trigger, group);
static BangKey? tryFromString(String key) {
try {
var [group, trigger] = key.split('::');
//Migrate to schema v5
if (group == 'assistant') {
group = BangGroup.kagi.name;
}
return BangKey(
group: BangGroup.values.firstWhere((g) => g.name == group),
trigger: trigger,
);
} catch (e, s) {
logger.w(
'Failed to parse BangKey from string: "$key"',
error: e,
stackTrace: s,
);
return null;
}
}
}
class BangKeyConverter implements JsonConverter<BangKey?, String?> {
const BangKeyConverter();
@override
BangKey? fromJson(String? json) {
return json.mapNotNull((json) => BangKey.tryFromString(json));
}
@override
String? toJson(BangKey? object) {
return object?.toString();
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class SearchHistoryEntry with FastEquatable {
final String searchQuery;
final String trigger;
final DateTime searchDate;
SearchHistoryEntry({
required this.searchQuery,
required this.trigger,
required this.searchDate,
});
@override
List<Object?> get hashParameters => [searchQuery, trigger, searchDate];
}
@@ -0,0 +1,55 @@
/*
* 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:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
import 'package:weblibre/core/database_registry.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
BangDatabase bangDatabase(Ref ref) {
final db = BangDatabase(
LazyDatabase(() async {
final file = File(p.join(filesystem.profileDatabasesDir.path, 'bang.db'));
// Also work around limitations on old Android versions
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
}
return NativeDatabase.createInBackground(file);
}),
);
DatabaseRegistry.instance.register('bang', db);
ref.onDispose(() async {
await db.close();
});
return db;
}
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(bangDatabase)
final bangDatabaseProvider = BangDatabaseProvider._();
final class BangDatabaseProvider
extends $FunctionalProvider<BangDatabase, BangDatabase, BangDatabase>
with $Provider<BangDatabase> {
BangDatabaseProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDatabaseProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDatabaseHash();
@$internal
@override
$ProviderElement<BangDatabase> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
BangDatabase create(Ref ref) {
return bangDatabase(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(BangDatabase value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<BangDatabase>(value),
);
}
}
String _$bangDatabaseHash() => r'0369d508def140a32c08c0551cefed57b1ca4b26';
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:exceptions/exceptions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/http_error_handler.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
part 'data_source.g.dart';
@Riverpod(keepAlive: true)
class BangDataSourceService extends _$BangDataSourceService {
@override
void build() {}
Future<Result<List<Bang>>> fetchRemoteBangs(Uri url, BangGroup group) {
return Result.fromAsync(() async {
return await compute((args) async {
final client = http.Client();
try {
final url = Uri.parse(args[0]);
final response = await client
.get(url)
.timeout(const Duration(seconds: 30));
return jsonDecode(utf8.decode(response.bodyBytes)) as List;
} finally {
client.close();
}
}, [url.toString()]).then(
(json) => json.map((e) {
final bang = Bang.fromJson(e as Map<String, dynamic>);
return bang.copyWith.group(group);
}).toList(),
);
}, exceptionHandler: handleHttpError);
}
Future<DateTime> getBundledBangDate(String path) async {
final content = await rootBundle.loadString(path);
return DateTime.parse(content.trim()).toLocal();
}
Future<Result<List<Bang>>> getBundledBangs(String path, BangGroup? group) {
return Result.fromAsync(() async {
final content = await rootBundle.loadString(path);
final json = jsonDecode(content) as List;
return json.map((e) {
var bang = Bang.fromJson(e as Map<String, dynamic>);
if (group != null) {
bang = bang.copyWith.group(group);
}
return bang;
}).toList();
});
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'data_source.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangDataSourceService)
final bangDataSourceServiceProvider = BangDataSourceServiceProvider._();
final class BangDataSourceServiceProvider
extends $NotifierProvider<BangDataSourceService, void> {
BangDataSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDataSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataSourceServiceHash();
@$internal
@override
BangDataSourceService create() => BangDataSourceService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangDataSourceServiceHash() =>
r'b1bd96bbd834de0f71af86d7f791d367d0a837be';
abstract class _$BangDataSourceService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,102 @@
/*
* 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/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'bangs.g.dart';
@Riverpod(keepAlive: true)
Stream<BangData?> defaultSearchBangData(Ref ref) {
final key = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.defaultSearchProvider,
),
);
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBang(key);
}
@Riverpod()
Stream<BangData?> bangData(Ref ref, BangKey key) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBang(key);
}
@Riverpod()
Stream<Map<String, List<String>>> bangCategories(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchCategories();
}
@Riverpod()
Stream<List<BangData>> bangList(
Ref ref, {
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBangs(
triggers: triggers,
groups: groups,
domain: domain,
categoryFilter: categoryFilter,
orderMostFrequentFirst: orderMostFrequentFirst,
);
}
@Riverpod()
Stream<List<BangData>> frequentBangList(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchFrequentBangs();
}
@Riverpod()
Stream<List<SearchHistoryEntry>> searchHistory(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
final maxSearchHistoryEntries = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.maxSearchHistoryEntries,
),
);
return repository.watchSearchHistory(limit: maxSearchHistoryEntries);
}
@Riverpod()
Stream<DateTime?> lastSyncOfGroup(Ref ref, BangGroup group) {
final repository = ref.watch(bangSyncRepositoryProvider.notifier);
return repository.watchLastSyncOfGroup(group);
}
@Riverpod()
Stream<int> bangCountOfGroup(Ref ref, BangGroup group) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBangCount(group);
}
@@ -0,0 +1,496 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bangs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(defaultSearchBangData)
final defaultSearchBangDataProvider = DefaultSearchBangDataProvider._();
final class DefaultSearchBangDataProvider
extends
$FunctionalProvider<AsyncValue<BangData?>, BangData?, Stream<BangData?>>
with $FutureModifier<BangData?>, $StreamProvider<BangData?> {
DefaultSearchBangDataProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'defaultSearchBangDataProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$defaultSearchBangDataHash();
@$internal
@override
$StreamProviderElement<BangData?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<BangData?> create(Ref ref) {
return defaultSearchBangData(ref);
}
}
String _$defaultSearchBangDataHash() =>
r'5f43b8989219cf3cb2f5ca65df351b6cb100427f';
@ProviderFor(bangData)
final bangDataProvider = BangDataFamily._();
final class BangDataProvider
extends
$FunctionalProvider<AsyncValue<BangData?>, BangData?, Stream<BangData?>>
with $FutureModifier<BangData?>, $StreamProvider<BangData?> {
BangDataProvider._({
required BangDataFamily super.from,
required BangKey super.argument,
}) : super(
retry: null,
name: r'bangDataProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataHash();
@override
String toString() {
return r'bangDataProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<BangData?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<BangData?> create(Ref ref) {
final argument = this.argument as BangKey;
return bangData(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BangDataProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangDataHash() => r'bd9f5ec8b29aab74620a9b5a4246cb7e3b2fd377';
final class BangDataFamily extends $Family
with $FunctionalFamilyOverride<Stream<BangData?>, BangKey> {
BangDataFamily._()
: super(
retry: null,
name: r'bangDataProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangDataProvider call(BangKey key) =>
BangDataProvider._(argument: key, from: this);
@override
String toString() => r'bangDataProvider';
}
@ProviderFor(bangCategories)
final bangCategoriesProvider = BangCategoriesProvider._();
final class BangCategoriesProvider
extends
$FunctionalProvider<
AsyncValue<Map<String, List<String>>>,
Map<String, List<String>>,
Stream<Map<String, List<String>>>
>
with
$FutureModifier<Map<String, List<String>>>,
$StreamProvider<Map<String, List<String>>> {
BangCategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangCategoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangCategoriesHash();
@$internal
@override
$StreamProviderElement<Map<String, List<String>>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, List<String>>> create(Ref ref) {
return bangCategories(ref);
}
}
String _$bangCategoriesHash() => r'947fcfd2dffcc7f585c6ed7379d319f4fe72293a';
@ProviderFor(bangList)
final bangListProvider = BangListFamily._();
final class BangListProvider
extends
$FunctionalProvider<
AsyncValue<List<BangData>>,
List<BangData>,
Stream<List<BangData>>
>
with $FutureModifier<List<BangData>>, $StreamProvider<List<BangData>> {
BangListProvider._({
required BangListFamily super.from,
required ({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
})
super.argument,
}) : super(
retry: null,
name: r'bangListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangListHash();
@override
String toString() {
return r'bangListProvider'
''
'$argument';
}
@$internal
@override
$StreamProviderElement<List<BangData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<BangData>> create(Ref ref) {
final argument =
this.argument
as ({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
});
return bangList(
ref,
triggers: argument.triggers,
groups: argument.groups,
domain: argument.domain,
categoryFilter: argument.categoryFilter,
orderMostFrequentFirst: argument.orderMostFrequentFirst,
);
}
@override
bool operator ==(Object other) {
return other is BangListProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangListHash() => r'd1e0bb9fa4f523ce516e075c0d149bf7803ebb2b';
final class BangListFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<BangData>>,
({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
})
> {
BangListFamily._()
: super(
retry: null,
name: r'bangListProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangListProvider call({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) => BangListProvider._(
argument: (
triggers: triggers,
groups: groups,
domain: domain,
categoryFilter: categoryFilter,
orderMostFrequentFirst: orderMostFrequentFirst,
),
from: this,
);
@override
String toString() => r'bangListProvider';
}
@ProviderFor(frequentBangList)
final frequentBangListProvider = FrequentBangListProvider._();
final class FrequentBangListProvider
extends
$FunctionalProvider<
AsyncValue<List<BangData>>,
List<BangData>,
Stream<List<BangData>>
>
with $FutureModifier<List<BangData>>, $StreamProvider<List<BangData>> {
FrequentBangListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'frequentBangListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$frequentBangListHash();
@$internal
@override
$StreamProviderElement<List<BangData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<BangData>> create(Ref ref) {
return frequentBangList(ref);
}
}
String _$frequentBangListHash() => r'2c1ecb7e9416772fc1c32d01d767e1eb4f865975';
@ProviderFor(searchHistory)
final searchHistoryProvider = SearchHistoryProvider._();
final class SearchHistoryProvider
extends
$FunctionalProvider<
AsyncValue<List<SearchHistoryEntry>>,
List<SearchHistoryEntry>,
Stream<List<SearchHistoryEntry>>
>
with
$FutureModifier<List<SearchHistoryEntry>>,
$StreamProvider<List<SearchHistoryEntry>> {
SearchHistoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchHistoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchHistoryHash();
@$internal
@override
$StreamProviderElement<List<SearchHistoryEntry>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<SearchHistoryEntry>> create(Ref ref) {
return searchHistory(ref);
}
}
String _$searchHistoryHash() => r'5f9508a6b286bfcd1b641bd429de46ad052a3dfe';
@ProviderFor(lastSyncOfGroup)
final lastSyncOfGroupProvider = LastSyncOfGroupFamily._();
final class LastSyncOfGroupProvider
extends
$FunctionalProvider<AsyncValue<DateTime?>, DateTime?, Stream<DateTime?>>
with $FutureModifier<DateTime?>, $StreamProvider<DateTime?> {
LastSyncOfGroupProvider._({
required LastSyncOfGroupFamily super.from,
required BangGroup super.argument,
}) : super(
retry: null,
name: r'lastSyncOfGroupProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$lastSyncOfGroupHash();
@override
String toString() {
return r'lastSyncOfGroupProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<DateTime?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<DateTime?> create(Ref ref) {
final argument = this.argument as BangGroup;
return lastSyncOfGroup(ref, argument);
}
@override
bool operator ==(Object other) {
return other is LastSyncOfGroupProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$lastSyncOfGroupHash() => r'23d07f3132ba9bb35a31f74e3a69698d31d4c569';
final class LastSyncOfGroupFamily extends $Family
with $FunctionalFamilyOverride<Stream<DateTime?>, BangGroup> {
LastSyncOfGroupFamily._()
: super(
retry: null,
name: r'lastSyncOfGroupProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
LastSyncOfGroupProvider call(BangGroup group) =>
LastSyncOfGroupProvider._(argument: group, from: this);
@override
String toString() => r'lastSyncOfGroupProvider';
}
@ProviderFor(bangCountOfGroup)
final bangCountOfGroupProvider = BangCountOfGroupFamily._();
final class BangCountOfGroupProvider
extends $FunctionalProvider<AsyncValue<int>, int, Stream<int>>
with $FutureModifier<int>, $StreamProvider<int> {
BangCountOfGroupProvider._({
required BangCountOfGroupFamily super.from,
required BangGroup super.argument,
}) : super(
retry: null,
name: r'bangCountOfGroupProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangCountOfGroupHash();
@override
String toString() {
return r'bangCountOfGroupProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<int> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<int> create(Ref ref) {
final argument = this.argument as BangGroup;
return bangCountOfGroup(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BangCountOfGroupProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangCountOfGroupHash() => r'211ffcd7f49b637a7953f913dc5eafda344423f3';
final class BangCountOfGroupFamily extends $Family
with $FunctionalFamilyOverride<Stream<int>, BangGroup> {
BangCountOfGroupFamily._()
: super(
retry: null,
name: r'bangCountOfGroupProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangCountOfGroupProvider call(BangGroup group) =>
BangCountOfGroupProvider._(argument: group, from: this);
@override
String toString() => r'bangCountOfGroupProvider';
}
@@ -0,0 +1,102 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'search.g.dart';
@Riverpod()
class BangSearch extends _$BangSearch {
late StreamController<List<BangData>> _streamController;
Future<Uri> triggerBangSearch(BangData bang, String searchQuery) async {
final bangDataNotifier = ref.read(bangDataRepositoryProvider.notifier);
final settings = ref.read(generalSettingsWithDefaultsProvider);
await bangDataNotifier.increaseFrequency(bang.toKey());
await bangDataNotifier.addSearchEntry(
bang.group,
bang.trigger,
searchQuery,
maxEntryCount: settings.maxSearchHistoryEntries,
);
return bang.getTemplateUrl(searchQuery);
}
Future<void> search(String input) async {
if (input.isNotEmpty) {
await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then(
(value) {
if (!_streamController.isClosed) {
_streamController.add(value);
}
},
);
}
}
@override
Stream<List<BangData>> build() {
_streamController = StreamController();
// Emit initial empty list so UI doesn't show loading state
_streamController.add([]);
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
}
@Riverpod()
class SeamlessBang extends _$SeamlessBang {
bool _hasSearch = false;
void search(String input) {
if (input.isNotEmpty) {
if (!_hasSearch) {
_hasSearch = true;
ref.invalidateSelf();
}
//Don't block
unawaited(ref.read(bangSearchProvider.notifier).search(input));
} else if (_hasSearch) {
_hasSearch = false;
ref.invalidateSelf();
}
}
@override
AsyncValue<List<BangData>> build() {
return _hasSearch
? ref.watch(bangSearchProvider)
: ref.watch(frequentBangListProvider);
}
}
@@ -0,0 +1,111 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangSearch)
final bangSearchProvider = BangSearchProvider._();
final class BangSearchProvider
extends $StreamNotifierProvider<BangSearch, List<BangData>> {
BangSearchProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangSearchProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangSearchHash();
@$internal
@override
BangSearch create() => BangSearch();
}
String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2';
abstract class _$BangSearch extends $StreamNotifier<List<BangData>> {
Stream<List<BangData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<List<BangData>>, List<BangData>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<BangData>>, List<BangData>>,
AsyncValue<List<BangData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(SeamlessBang)
final seamlessBangProvider = SeamlessBangProvider._();
final class SeamlessBangProvider
extends $NotifierProvider<SeamlessBang, AsyncValue<List<BangData>>> {
SeamlessBangProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'seamlessBangProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$seamlessBangHash();
@$internal
@override
SeamlessBang create() => SeamlessBang();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<List<BangData>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<List<BangData>>>(value),
);
}
}
String _$seamlessBangHash() => r'8bd7a2cbe4c302ae08f85167290666a7437f8b9b';
abstract class _$SeamlessBang extends $Notifier<AsyncValue<List<BangData>>> {
AsyncValue<List<BangData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<List<BangData>>, AsyncValue<List<BangData>>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<BangData>>,
AsyncValue<List<BangData>>
>,
AsyncValue<List<BangData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
part 'data.g.dart';
@Riverpod(keepAlive: true)
class BangDataRepository extends _$BangDataRepository {
@override
void build() {}
Stream<BangData?> watchBang(BangKey? key) {
if (key != null) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangData(key.group, key.trigger)
.watchSingleOrNull();
} else {
return Stream.value(null);
}
}
Stream<Map<String, List<String>>> watchCategories() {
return ref
.read(bangDatabaseProvider)
.definitionsDrift
.categoriesJson()
.watchSingle()
.map((json) {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map(
(key, value) => MapEntry(key, (value as List<dynamic>).cast()),
);
});
}
Stream<int> watchBangCount(BangGroup group) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangCount(groups: [group])
.watchSingle();
}
Stream<List<BangData>> watchBangs({
Iterable<String>? triggers,
Iterable<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangDataList(
triggers: triggers,
groups: groups,
domain: domain,
category: categoryFilter?.category,
subCategory: categoryFilter?.subCategory,
orderMostFrequentFirst: orderMostFrequentFirst,
)
.watch();
}
Stream<List<BangData>> watchFrequentBangs({Iterable<BangGroup>? groups}) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getFrequentBangDataList(groups: groups)
.watch();
}
Stream<List<SearchHistoryEntry>> watchSearchHistory({required int limit}) {
return ref
.read(bangDatabaseProvider)
.definitionsDrift
.searchHistoryEntries(limit: limit)
.watch();
}
Future<void> increaseFrequency(BangKey key) {
return ref.read(bangDatabaseProvider).bangDao.increaseBangFrequency(key);
}
Future<void> addSearchEntry(
BangGroup group,
String trigger,
String searchQuery, {
required int maxEntryCount,
}) async {
// Skip capturing history if maxEntryCount is 0
if (maxEntryCount <= 0) {
return;
}
final db = ref.read(bangDatabaseProvider);
//Pack in a transaction to bundle rebuilds of watch() queries
return db.transaction(() async {
await db.bangDao.addSearchEntry(group, trigger, searchQuery);
await db.definitionsDrift.evictHistoryEntries(limit: maxEntryCount);
});
}
Future<void> removeSearchEntry(String searchQuery) {
return ref
.read(bangDatabaseProvider)
.bangDao
.removeSearchEntry(searchQuery);
}
Future<int> resetFrequencies() {
return ref.read(bangDatabaseProvider).bangFrequency.deleteAll();
}
Future<int> resetFrequency(String trigger) {
return ref
.read(bangDatabaseProvider)
.bangFrequency
.deleteWhere((t) => t.trigger.equals(trigger));
}
Future<BangData?> getBang(BangKey key) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangData(key.group, key.trigger)
.getSingleOrNull();
}
Future<void> upsertBang(Bang bang) {
return ref.read(bangDatabaseProvider).bangDao.upsertBang(bang);
}
Future<void> deleteBang(BangKey key) {
return ref.read(bangDatabaseProvider).syncDao.deleteBangs(key.group, [
key.trigger,
]);
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'data.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangDataRepository)
final bangDataRepositoryProvider = BangDataRepositoryProvider._();
final class BangDataRepositoryProvider
extends $NotifierProvider<BangDataRepository, void> {
BangDataRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDataRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataRepositoryHash();
@$internal
@override
BangDataRepository create() => BangDataRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangDataRepositoryHash() =>
r'c562ef10d75ca6dee13805f84d2491cf2a89aaac';
abstract class _$BangDataRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,191 @@
/*
* 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:exceptions/exceptions.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/bangs/data/services/data_source.dart';
part 'sync.g.dart';
@Riverpod(keepAlive: true)
class BangSyncRepository extends _$BangSyncRepository {
static Future<Result<void>> _fetchAndSyncRemote({
required BangDataSourceService sourceService,
required BangDatabase db,
required Uri url,
required BangGroup group,
required Duration? syncInterval,
}) async {
if (syncInterval != null) {
final lastSync = await db.syncDao
.getLastSyncOfGroup(group)
.getSingleOrNull();
if (lastSync != null &&
DateTime.now().difference(lastSync) < syncInterval) {
return Result.success(null);
}
}
final result = await sourceService.fetchRemoteBangs(url, group);
return result.flatMapAsync((remoteBangs) async {
await db.syncDao.syncBangs(
group: group,
remoteBangs: remoteBangs,
syncTime: DateTime.now(),
);
await db.definitionsDrift.optimizeBangFtsIndex();
await db.definitionsDrift.optimizeTriggerFtsIndex();
});
}
static Future<Result<void>> _fetchAndSyncBundled({
required BangDataSourceService sourceService,
required BangDatabase db,
required BangGroup group,
}) async {
if (group.bundled == null) {
return Result.failure(
const ErrorMessage(source: 'BangSync', message: 'Not bundled'),
);
}
if (group.remote == null) {
return Result.failure(
const ErrorMessage(source: 'BangSync', message: 'No remote source'),
);
}
final lastSync = await db.syncDao
.getLastSyncOfGroup(group)
.getSingleOrNull();
final sourceDate = await sourceService.getBundledBangDate(
'assets/bangs/last_sync.txt',
);
if (lastSync != null &&
(sourceDate == lastSync ||
sourceDate.difference(lastSync).isNegative)) {
return Result.success(null);
}
final result = await sourceService.getBundledBangs(group.bundled!, group);
return result.flatMapAsync((remoteBangs) async {
await db.syncDao.syncBangs(
group: group,
remoteBangs: remoteBangs,
syncTime: sourceDate,
);
await db.definitionsDrift.optimizeBangFtsIndex();
await db.definitionsDrift.optimizeTriggerFtsIndex();
});
}
Future<Result<void>> syncRemoteBangGroup(
BangGroup group,
Duration? syncInterval,
) async {
try {
return Result.success(
await ref
.read(bangDatabaseProvider)
.computeWithDatabase(
connect: BangDatabase.new,
computation: (db) async {
final ref = ProviderContainer();
final result = await _fetchAndSyncRemote(
sourceService: ref.read(
bangDataSourceServiceProvider.notifier,
),
db: db,
url: Uri.parse(group.remote!),
group: group,
syncInterval: syncInterval,
);
//Throw if necessary
return result.value;
},
),
);
} catch (e) {
return Result.failure(
ErrorMessage(
message: "Failed to sync Bangs (${group.name})",
source: 'BangSync',
details: e,
),
);
}
}
Future<Result<void>> syncBundledBangGroup(BangGroup group) async {
try {
final db = ref.read(bangDatabaseProvider);
final result = await _fetchAndSyncBundled(
sourceService: ref.read(bangDataSourceServiceProvider.notifier),
db: db,
group: group,
);
//Throw if necessary
return result;
} catch (e) {
return Result.failure(
ErrorMessage(
message: "Failed to sync Bangs (${group.name})",
source: 'BangSync',
details: e,
),
);
}
}
Stream<DateTime?> watchLastSyncOfGroup(BangGroup group) {
return ref
.read(bangDatabaseProvider)
.syncDao
.getLastSyncOfGroup(group)
.watchSingleOrNull();
}
Future<Map<BangGroup, Result<void>>> syncBundledBangGroups({
Set<BangGroup>? groups,
}) async {
//Default to all sources
groups ??= BangGroup.values.where((e) => e.bundled != null).toSet();
//Run isolated operations
final futures = groups.map(
(source) => syncBundledBangGroup(
source,
).then((result) => MapEntry(source, result)),
);
return Map.fromEntries(await Future.wait(futures));
}
@override
void build() {}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangSyncRepository)
final bangSyncRepositoryProvider = BangSyncRepositoryProvider._();
final class BangSyncRepositoryProvider
extends $NotifierProvider<BangSyncRepository, void> {
BangSyncRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangSyncRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangSyncRepositoryHash();
@$internal
@override
BangSyncRepository create() => BangSyncRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangSyncRepositoryHash() =>
r'1347dcbd03f6a1a4fa3bbbae5d9302098d260c50';
abstract class _$BangSyncRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,46 @@
/*
* 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/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'search_history_cleanup.g.dart';
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
@Riverpod(keepAlive: true)
class SearchHistoryCleanupService extends _$SearchHistoryCleanupService {
@override
void build() {
ref.listen(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.maxSearchHistoryEntries,
),
(previous, next) async {
// Only cleanup when limit is reduced (including to 0)
if (previous != null && next < previous) {
final db = ref.read(bangDatabaseProvider);
await db.definitionsDrift.evictHistoryEntries(limit: next);
}
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More