Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,181 @@
/*
* 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:supabase/supabase.dart';
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
part 'account_sync_repository.g.dart';
enum SyncDocumentKind {
weblibreSettings('weblibre_settings', 'Settings'),
geckoUserJs('gecko_user_js', 'Gecko Prefs'),
/// Small encrypted canary written on first-device sync setup so a second
/// device can verify the candidate sync key before persisting it. Not
/// surfaced in any settings UI — it has no corresponding
/// [SyncDocumentService] wired in [AccountSettingsScreen].
syncValidationProbe('sync_validation_probe', 'Sync Validation Probe');
final String value;
final String displayName;
const SyncDocumentKind(this.value, this.displayName);
}
class SyncDocumentMetadata {
final String id;
final String? label;
final DateTime createdAt;
final DateTime updatedAt;
final String? sourceDeviceId;
final String? sourceAppVersion;
final int schemaVersion;
SyncDocumentMetadata({
required this.id,
this.label,
required this.createdAt,
required this.updatedAt,
required this.schemaVersion,
this.sourceDeviceId,
this.sourceAppVersion,
});
factory SyncDocumentMetadata.fromRow(Map<String, dynamic> row) {
return SyncDocumentMetadata(
id: row['id'] as String,
label: row['label'] as String?,
createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String),
schemaVersion: row['schema_version'] as int,
sourceDeviceId: row['source_device_id'] as String?,
sourceAppVersion: row['source_app_version'] as String?,
);
}
}
class SyncDocumentResult {
final String contentBlob;
final SyncDocumentMetadata metadata;
SyncDocumentResult({required this.contentBlob, required this.metadata});
}
/// All write methods on this repository require the user to be signed in.
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
/// `updateLabel`. Calling them on a signed-out instance throws
/// [StateError]; the repository deliberately doesn't fail silently because
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
@Riverpod(keepAlive: true)
class AccountSyncRepository extends _$AccountSyncRepository {
@override
SupabaseClient? build() {
final authState = ref.watch(accountAuthRepositoryProvider).value;
if (authState == null || !authState.isSignedIn) return null;
return authState.client;
}
SupabaseClient get _client {
final client = state;
if (client == null) {
throw StateError(
'AccountSyncRepository called while signed-out. '
'Gate the call site on accountAuthRepositoryProvider.isSignedIn '
'or accountSyncRepositoryProvider != null first.',
);
}
return client;
}
/// Stores a new document. Always inserts a new row.
/// Returns the generated document UUID.
Future<String> storeDocument({
required SyncDocumentKind kind,
required int schemaVersion,
required String contentBlob,
String? label,
String? sourceDeviceId,
String? sourceAppVersion,
}) async {
final row = await _client
.from('account_sync_documents')
.insert({
'user_id': _client.auth.currentUser!.id,
'document_kind': kind.value,
'label': label,
'schema_version': schemaVersion,
'content_blob': contentBlob,
'source_device_id': sourceDeviceId,
'source_app_version': sourceAppVersion,
})
.select('id')
.single();
return row['id'] as String;
}
/// Lists all documents of a given kind, ordered by most recent first.
/// Returns metadata only (no content).
Future<List<SyncDocumentMetadata>> listDocuments({
required SyncDocumentKind kind,
}) async {
final rows = await _client
.from('account_sync_documents')
.select(
'id, label, created_at, updated_at, schema_version, '
'source_device_id, source_app_version',
)
.eq('document_kind', kind.value)
.order('updated_at', ascending: false);
return rows.map(SyncDocumentMetadata.fromRow).toList();
}
/// Fetches a single document by ID (with encrypted content blob).
Future<SyncDocumentResult?> fetchDocument({required String id}) async {
final row = await _client
.from('account_sync_documents')
.select()
.eq('id', id)
.maybeSingle();
if (row == null) return null;
return SyncDocumentResult(
contentBlob: row['content_blob'] as String,
metadata: SyncDocumentMetadata.fromRow(row),
);
}
/// Deletes a document by ID.
Future<void> deleteDocument({required String id}) async {
await _client.from('account_sync_documents').delete().eq('id', id);
}
/// Updates the label of a document.
Future<void> updateLabel({required String id, required String? label}) async {
await _client
.from('account_sync_documents')
.update({'label': label})
.eq('id', id);
}
}
@@ -0,0 +1,92 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_sync_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// All write methods on this repository require the user to be signed in.
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
/// `updateLabel`. Calling them on a signed-out instance throws
/// [StateError]; the repository deliberately doesn't fail silently because
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
@ProviderFor(AccountSyncRepository)
final accountSyncRepositoryProvider = AccountSyncRepositoryProvider._();
/// All write methods on this repository require the user to be signed in.
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
/// `updateLabel`. Calling them on a signed-out instance throws
/// [StateError]; the repository deliberately doesn't fail silently because
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
final class AccountSyncRepositoryProvider
extends $NotifierProvider<AccountSyncRepository, SupabaseClient?> {
/// All write methods on this repository require the user to be signed in.
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
/// `updateLabel`. Calling them on a signed-out instance throws
/// [StateError]; the repository deliberately doesn't fail silently because
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
AccountSyncRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'accountSyncRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountSyncRepositoryHash();
@$internal
@override
AccountSyncRepository create() => AccountSyncRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SupabaseClient? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SupabaseClient?>(value),
);
}
}
String _$accountSyncRepositoryHash() =>
r'ce558b699974edce2ca06b3990cf09c245d09126';
/// All write methods on this repository require the user to be signed in.
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
/// `updateLabel`. Calling them on a signed-out instance throws
/// [StateError]; the repository deliberately doesn't fail silently because
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
abstract class _$AccountSyncRepository extends $Notifier<SupabaseClient?> {
SupabaseClient? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<SupabaseClient?, SupabaseClient?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SupabaseClient?, SupabaseClient?>,
SupabaseClient?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}