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,223 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/device_info.dart';
import 'package:weblibre/features/about/domain/providers.dart';
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
import 'package:weblibre/features/account/data/models/subscription_status.dart';
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
import 'package:weblibre/features/account/domain/services/prefs_sync_service.dart';
import 'package:weblibre/features/account/domain/services/settings_sync_service.dart';
import 'package:weblibre/features/account/presentation/widgets/account_auth_status_card.dart';
import 'package:weblibre/features/account/presentation/widgets/subscription_card.dart';
import 'package:weblibre/features/account/presentation/widgets/sync_document_list_section.dart';
import 'package:weblibre/features/account/presentation/widgets/sync_setup_card.dart';
import 'package:weblibre/features/search_credits/presentation/widgets/search_credits_section.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
class AccountSettingsScreen extends HookConsumerWidget {
const AccountSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authAsync = ref.watch(accountAuthRepositoryProvider);
final subscriptionAsync = ref.watch(subscriptionRepositoryProvider);
final search = useSettingsSearch();
Widget buildBody(Widget sliver) {
return SettingsCustomScrollScaffold(
title: 'WebLibre Account',
searchController: search.controller,
searchHintText: 'Search account settings',
slivers: [sliver],
);
}
return authAsync.when(
loading: () => buildBody(
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
),
),
error: (_, _) => buildBody(
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('Failed to load account')),
),
),
data: (authState) {
final sections = _buildSections(
ref: ref,
authState: authState,
subscriptionAsync: subscriptionAsync,
);
final filteredSections = filterSettingsSections(
sections: sections,
query: search.rawQuery,
);
return buildBody(
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: SettingsSectionList(
sections: filteredSections,
query: search.rawQuery,
),
),
),
);
},
);
}
List<SettingsSectionDefinition> _buildSections({
required WidgetRef ref,
required AccountAuthState authState,
required AsyncValue<SubscriptionStatus> subscriptionAsync,
}) {
final showSyncSnapshots =
authState.isSignedIn && subscriptionAsync.value?.isActive == true;
final syncClient = showSyncSnapshots
? ref.read(accountSyncRepositoryProvider)
: null;
final syncRepo = syncClient != null
? ref.read(accountSyncRepositoryProvider.notifier)
: null;
final sourceDeviceId = ref.read(androidDeviceInfoProvider).value?.deviceName;
final sourceAppVersion = _appVersion(ref);
return <SettingsSectionDefinition>[
SettingsSectionDefinition(
title: 'Account',
entries: [
SettingsEntryDefinition(
title: switch (authState.status) {
AccountAuthStatus.signedOut => 'Sign in to WebLibre Account',
AccountAuthStatus.signingIn => 'Signing in',
AccountAuthStatus.signedIn => 'Signed in account',
AccountAuthStatus.error => 'Sign-in failed',
},
subtitle: switch (authState.status) {
AccountAuthStatus.signedOut => 'Sync your settings across devices',
AccountAuthStatus.signingIn => 'Complete sign-in in your browser',
AccountAuthStatus.signedIn =>
authState.displayName ?? authState.email ?? 'Signed in',
AccountAuthStatus.error => authState.lastError,
},
keywords: [
'sign in',
'account',
'authentication',
if (authState.hasSyncKey) ...['sync key', 'reset sync key'],
],
child: AccountAuthStatusCard(authState: authState),
),
],
),
if (authState.isSignedIn)
SettingsSectionDefinition(
title: 'Subscription',
entries: [
SettingsEntryDefinition(
title: 'Supporter subscription',
subtitle: 'Status, billing, and subscription management',
keywords: const ['billing', 'supporter'],
child: SubscriptionCard(subscriptionAsync: subscriptionAsync),
),
],
),
if (authState.isSignedIn)
const SettingsSectionDefinition(
title: 'Search Credits',
entries: [
SettingsEntryDefinition(
title: 'Search credits',
subtitle: 'Credits balance, token issuance, and purchases',
keywords: ['tokens', 'search pack'],
child: SearchCreditsSection(embedded: true),
),
],
),
if (showSyncSnapshots && syncRepo != null)
if (authState.hasSyncKey) ...[
SettingsSectionDefinition(
title: 'Settings Snapshots',
entries: [
SettingsEntryDefinition(
title: 'Settings snapshots',
subtitle: 'Store and restore synced application settings',
keywords: const ['backups', 'settings sync'],
child: SyncDocumentListSection(
service: ref.read(settingsSyncServiceProvider.notifier),
syncRepo: syncRepo,
syncKey: authState.syncKey!,
sourceDeviceId: sourceDeviceId,
sourceAppVersion: sourceAppVersion,
embedded: true,
),
),
],
),
SettingsSectionDefinition(
title: 'Preferences Snapshots',
entries: [
SettingsEntryDefinition(
title: 'Preferences snapshots',
subtitle: 'Store and restore synced preference documents',
keywords: const ['backups', 'prefs sync'],
child: SyncDocumentListSection(
service: ref.read(prefsSyncServiceProvider.notifier),
syncRepo: syncRepo,
syncKey: authState.syncKey!,
sourceDeviceId: sourceDeviceId,
sourceAppVersion: sourceAppVersion,
embedded: true,
),
),
],
),
] else
SettingsSectionDefinition(
title: 'Encrypted Sync',
entries: [
SettingsEntryDefinition(
title: 'Set up encrypted sync',
subtitle:
'Enable end-to-end encrypted sync using your account password',
keywords: const ['sync key', 'backups', 'snapshots'],
child: SyncSetupCard(email: authState.email),
),
],
),
];
}
}
String? _appVersion(WidgetRef ref) {
final info = ref.read(packageInfoProvider).value;
if (info == null) return null;
return '${info.version}+${info.buildNumber}';
}
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
/// Body of the "Account" settings entry. Renders the auth state machine:
/// signed-out CTA, signing-in spinner with cancel, signed-in identity with
/// sign-out + sync-key reset, or an error tile with retry.
class AccountAuthStatusCard extends ConsumerWidget {
const AccountAuthStatusCard({super.key, required this.authState});
final AccountAuthState authState;
@override
Widget build(BuildContext context, WidgetRef ref) {
return switch (authState.status) {
AccountAuthStatus.signedOut => const _SignedOutTile(),
AccountAuthStatus.signingIn => const _SigningInTile(),
AccountAuthStatus.signedIn => _SignedInTile(authState: authState),
AccountAuthStatus.error => _ErrorTile(authState: authState),
};
}
}
class _SignedOutTile extends ConsumerWidget {
const _SignedOutTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(Icons.login),
title: const Text('Sign in to WebLibre Account'),
subtitle: const Text('Sync your settings across devices'),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await ref.read(accountAuthRepositoryProvider.notifier).startSignIn();
},
);
}
}
class _SigningInTile extends ConsumerWidget {
const _SigningInTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Signing in...'),
const SizedBox(height: 8),
Text(
'Complete sign-in in your browser',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextButton(
onPressed: () async {
await ref
.read(accountAuthRepositoryProvider.notifier)
.cancelSignIn();
},
child: const Text('Cancel'),
),
],
),
);
}
}
class _SignedInTile extends ConsumerWidget {
const _SignedInTile({required this.authState});
final AccountAuthState authState;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
ListTile(
leading: const Icon(Icons.account_circle),
title: Text(authState.displayName ?? authState.email ?? 'Signed in'),
subtitle:
authState.email != null &&
authState.email != authState.displayName
? Text(authState.email!)
: null,
trailing: IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Sign Out',
onPressed: () async {
final confirmed = await _showSignOutConfirmation(context);
if (confirmed == true) {
await ref
.read(accountAuthRepositoryProvider.notifier)
.signOut();
}
},
),
contentPadding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
),
if (authState.hasSyncKey) ...[
const Divider(height: 1),
const _ResetSyncKeyTile(),
],
],
);
}
static Future<bool?> _showSignOutConfirmation(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Sign out?'),
content: const Text(
'Are you sure you want to sign out of your WebLibre Account?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Sign Out'),
),
],
);
},
);
}
}
class _ErrorTile extends ConsumerWidget {
const _ErrorTile({required this.authState});
final AccountAuthState authState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sign-in failed',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
if (authState.lastError != null) ...[
const SizedBox(height: 8),
Text(
authState.lastError!,
style: TextStyle(color: colorScheme.onErrorContainer),
),
],
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () async {
await ref
.read(accountAuthRepositoryProvider.notifier)
.startSignIn();
},
child: const Text('Try Again'),
),
],
),
),
),
);
}
}
class _ResetSyncKeyTile extends ConsumerWidget {
const _ResetSyncKeyTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(Icons.key_off_outlined),
title: const Text('Reset Sync Key'),
subtitle: const Text(
'Re-enter your password if you mistyped it or changed it',
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Sync Key'),
content: const Text(
'You will need to re-enter your account password. '
'If your password changed, existing snapshots '
'encrypted with the old password will no longer '
'be decryptable.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true) {
await ref.read(accountAuthRepositoryProvider.notifier).clearSyncKey();
}
},
);
}
}
@@ -0,0 +1,308 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/features/account/data/models/subscription_status.dart';
import 'package:weblibre/features/account/data/supabase_config.dart';
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
/// Visual presentation of one subscription state. All branches of the
/// subscription UI render through the same ListTile + badge + note + manage
/// button structure — the differences boil down to these fields, which the
/// state machine selects in `_resolvePresentation`.
class _SubscriptionPresentation {
final IconData leadingIcon;
final Color? leadingIconColor;
final String planTitle;
final String badgeLabel;
final Color badgeColor;
final Color badgeTextColor;
final String? note;
final Color? noteColor;
final String manageLabel;
final String? subtitle;
final DateTime? expiryHint;
const _SubscriptionPresentation({
required this.leadingIcon,
required this.planTitle,
required this.badgeLabel,
required this.badgeColor,
required this.badgeTextColor,
required this.manageLabel,
this.leadingIconColor,
this.note,
this.noteColor,
this.subtitle,
this.expiryHint,
});
}
class SubscriptionCard extends HookConsumerWidget {
const SubscriptionCard({super.key, required this.subscriptionAsync});
final AsyncValue<SubscriptionStatus> subscriptionAsync;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Refresh on resume regardless of which branch is currently rendered.
// Keeping the hook at the top level means a stale error tile (e.g. the
// user opened the screen offline, then reconnected and returned to the
// app) still gets a fresh fetch — previously the hook only ran in the
// `data` branch and never fired from the error state.
useOnAppLifecycleStateChange((previous, current) async {
if (current == AppLifecycleState.resumed) {
await ref.read(subscriptionRepositoryProvider.notifier).refresh();
}
});
return subscriptionAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(child: CircularProgressIndicator()),
),
// Render a dedicated error tile so the user can tell "fetch failed"
// apart from "no subscription" — they have different fixes (retry
// vs. subscribe).
error: (_, _) => _SubscriptionErrorTile(
onRetry: () =>
ref.read(subscriptionRepositoryProvider.notifier).refresh(),
),
data: (status) {
final presentation = _resolvePresentation(context, status);
return _SubscriptionStateBody(presentation: presentation);
},
);
}
}
class _SubscriptionErrorTile extends StatelessWidget {
const _SubscriptionErrorTile({required this.onRetry});
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(Icons.error_outline, color: scheme.error),
title: const Text('Could not load subscription'),
subtitle: const Text('Check your connection and try again.'),
trailing: IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Retry',
onPressed: onRetry,
),
);
}
}
_SubscriptionPresentation _resolvePresentation(
BuildContext context,
SubscriptionStatus status,
) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final planTitle = status.planLabel ?? 'Supporter';
if (status.isActive) {
final isWindingDown = status.isWindingDown;
final endDate = status.currentPeriodEnd ?? status.entitledUntil;
return _SubscriptionPresentation(
leadingIcon: Icons.verified,
leadingIconColor: scheme.primary,
planTitle: planTitle,
badgeLabel: isWindingDown ? 'Will not renew' : 'Active',
badgeColor: isWindingDown
? scheme.surfaceContainerHighest
: scheme.primaryContainer,
badgeTextColor: isWindingDown
? scheme.onSurface
: scheme.onPrimaryContainer,
subtitle: status.entitledUntil != null
? 'Until ${_formatDate(status.entitledUntil!)}'
: null,
expiryHint: isWindingDown ? endDate : null,
manageLabel: 'Manage Subscription',
);
}
if (status.isPaused) {
return _SubscriptionPresentation(
leadingIcon: Icons.pause_circle_outline,
leadingIconColor: scheme.onSurfaceVariant,
planTitle: planTitle,
badgeLabel: 'Paused',
badgeColor: scheme.tertiaryContainer,
badgeTextColor: scheme.onTertiaryContainer,
note:
'Your subscription is paused. Resume it from the customer '
'portal to restore access.',
noteColor: scheme.onSurfaceVariant,
manageLabel: 'Manage Subscription',
);
}
if (status.isPastDue) {
return _SubscriptionPresentation(
leadingIcon: Icons.error_outline,
leadingIconColor: scheme.onSurfaceVariant,
planTitle: planTitle,
badgeLabel: 'Past due',
badgeColor: scheme.errorContainer,
badgeTextColor: scheme.onErrorContainer,
note:
'Payment failed. Update your payment method to keep your '
'subscription active.',
noteColor: scheme.error,
manageLabel: 'Update Payment Method',
);
}
if (status.isWindingDown) {
// No active entitlement remains (isActive was false above) — the
// grace period has ended or there never was one. Offer renewal.
return _SubscriptionPresentation(
leadingIcon: Icons.history_toggle_off,
leadingIconColor: scheme.onSurfaceVariant,
planTitle: planTitle,
badgeLabel: 'Will not renew',
badgeColor: scheme.surfaceContainerHighest,
badgeTextColor: scheme.onSurface,
note:
'Your subscription has ended. Renew from the customer '
'portal to continue.',
noteColor: scheme.onSurfaceVariant,
manageLabel: 'Renew Subscription',
);
}
return _inactivePresentation(context);
}
_SubscriptionPresentation _inactivePresentation(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return _SubscriptionPresentation(
leadingIcon: Icons.card_membership,
leadingIconColor: scheme.onSurfaceVariant,
planTitle: 'Supporter Subscription',
subtitle: 'Subscribe to unlock sync features',
badgeLabel: 'Inactive',
badgeColor: scheme.surfaceContainerHighest,
badgeTextColor: scheme.onSurface,
manageLabel: 'Subscribe',
);
}
String _formatDate(DateTime date) => DateFormat.yMMMd().format(date);
class _SubscriptionStateBody extends ConsumerWidget {
const _SubscriptionStateBody({required this.presentation});
final _SubscriptionPresentation presentation;
Future<void> _openPortal() async {
await launchUrl(
Uri.parse(SupabaseConfig.accountWebUrl),
mode: LaunchMode.inAppBrowserView,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Column(
children: [
ListTile(
leading: Icon(
presentation.leadingIcon,
color: presentation.leadingIconColor,
),
title: Row(
children: [
Text(presentation.planTitle),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: presentation.badgeColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
presentation.badgeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: presentation.badgeTextColor,
),
),
),
],
),
subtitle: presentation.subtitle != null
? Text(presentation.subtitle!)
: null,
trailing: IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh status',
onPressed: () async {
await ref.read(subscriptionRepositoryProvider.notifier).refresh();
},
),
),
if (presentation.expiryHint != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.warning_amber, size: 16, color: scheme.error),
const SizedBox(width: 4),
Expanded(
child: Text(
'Your subscription will end on '
'${_formatDate(presentation.expiryHint!)}',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.error,
),
),
),
],
),
),
if (presentation.note != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text(
presentation.note!,
style: theme.textTheme.bodySmall?.copyWith(
color: presentation.noteColor,
),
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.open_in_new),
title: Text(presentation.manageLabel),
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
onTap: _openPortal,
),
],
);
}
}
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
// -- Metadata display helpers ------------------------------------------------
class MetadataRow extends StatelessWidget {
const MetadataRow({super.key, required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
),
],
),
);
}
}
String formatDateTime(DateTime dt) {
final local = dt.toLocal();
return '${local.year}-${_pad(local.month)}-${_pad(local.day)} '
'${_pad(local.hour)}:${_pad(local.minute)}';
}
String _pad(int n) => n.toString().padLeft(2, '0');
// -- Dialogs -----------------------------------------------------------------
Future<String?> showStoreLabelDialog(BuildContext context) {
final controller = TextEditingController();
return showDialog<String?>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Store Snapshot'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Label (optional)',
hintText: 'e.g. "Before update", "Home setup"',
border: OutlineInputBorder(),
),
autofocus: true,
textCapitalization: TextCapitalization.sentences,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final label = controller.text.trim();
Navigator.of(context).pop(label.isEmpty ? '' : label);
},
child: const Text('Store'),
),
],
),
);
}
Future<String?> showEditLabelDialog(
BuildContext context, {
String? currentLabel,
}) {
final controller = TextEditingController(text: currentLabel);
return showDialog<String?>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit Label'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Label',
border: OutlineInputBorder(),
),
autofocus: true,
textCapitalization: TextCapitalization.sentences,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final label = controller.text.trim();
Navigator.of(context).pop(label.isEmpty ? '' : label);
},
child: const Text('Save'),
),
],
),
);
}
Future<bool?> showRestoreConfirmation(
BuildContext context, {
required SyncDocumentMetadata metadata,
}) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Restore Snapshot'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('This will overwrite your current local settings.'),
const SizedBox(height: 16),
if (metadata.label != null && metadata.label!.isNotEmpty)
MetadataRow(label: 'Label', value: metadata.label!),
MetadataRow(
label: 'Stored',
value: formatDateTime(metadata.updatedAt),
),
if (metadata.sourceAppVersion != null)
MetadataRow(
label: 'App version',
value: metadata.sourceAppVersion!,
),
if (metadata.sourceDeviceId != null)
MetadataRow(label: 'Device', value: metadata.sourceDeviceId!),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Restore'),
),
],
),
);
}
Future<bool?> showDeleteConfirmation(
BuildContext context, {
required SyncDocumentMetadata metadata,
}) {
final label = metadata.label?.isNotEmpty == true
? '"${metadata.label}"'
: 'this snapshot';
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Snapshot'),
content: Text('Are you sure you want to delete $label?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
}
@@ -0,0 +1,382 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:secure_archive/secure_archive.dart';
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
import 'package:weblibre/features/account/presentation/widgets/sync_document_dialogs.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart';
class SyncDocumentListSection extends HookWidget {
const SyncDocumentListSection({
super.key,
required this.service,
required this.syncRepo,
required this.syncKey,
this.sourceDeviceId,
this.sourceAppVersion,
this.embedded = false,
});
final SyncDocumentService service;
final AccountSyncRepository syncRepo;
final String syncKey;
final String? sourceDeviceId;
final String? sourceAppVersion;
final bool embedded;
@override
Widget build(BuildContext context) {
final documents = useState<List<SyncDocumentMetadata>?>(null);
final loading = useState(true);
final busy = useState(false);
final secureData = useMemoized(
() => SecureData(argon2Params: Argon2Params.memoryConstrained()),
);
Future<void> refresh() async {
try {
final docs = await syncRepo.listDocuments(kind: service.kind);
documents.value = docs;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load snapshots: $e')),
);
}
} finally {
if (context.mounted) {
loading.value = false;
}
}
}
useEffect(() {
unawaited(refresh());
return null;
}, const []);
Future<void> storeCurrent() async {
final labelResult = await showStoreLabelDialog(context);
if (labelResult == null) return;
busy.value = true;
try {
final plaintext = await service.serializeCurrent();
final encrypted = await secureData.encrypt(
plaintext,
syncKey,
compress: true,
);
final blob = base64Encode(encrypted);
final label = labelResult.isEmpty ? null : labelResult;
await syncRepo.storeDocument(
kind: service.kind,
schemaVersion: service.schemaVersion,
contentBlob: blob,
label: label,
sourceDeviceId: sourceDeviceId,
sourceAppVersion: sourceAppVersion,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${service.kind.displayName} stored')),
);
}
await refresh();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to store: $e')));
}
} finally {
if (context.mounted) {
busy.value = false;
}
}
}
Future<void> restore(SyncDocumentMetadata metadata) async {
final confirmed = await showRestoreConfirmation(
context,
metadata: metadata,
);
if (confirmed != true) return;
busy.value = true;
try {
final result = await syncRepo.fetchDocument(id: metadata.id);
if (result == null) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Snapshot not found')));
}
return;
}
final encrypted = base64Decode(result.contentBlob);
final plaintext = await secureData.decrypt(encrypted, syncKey);
await service.applyRestored(plaintext);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${service.kind.displayName} restored')),
);
}
} catch (e) {
if (context.mounted) {
if (_isDecryptionFailure(e)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Decryption failed — wrong password or data corrupted. '
'Try resetting your sync key.',
),
),
);
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to restore: $e')));
}
}
} finally {
if (context.mounted) {
busy.value = false;
}
}
}
Future<void> editLabel(SyncDocumentMetadata metadata) async {
final newLabel = await showEditLabelDialog(
context,
currentLabel: metadata.label,
);
if (newLabel == null) return;
try {
await syncRepo.updateLabel(
id: metadata.id,
label: newLabel.isEmpty ? null : newLabel,
);
await refresh();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to update label: $e')));
}
}
}
Future<void> delete(SyncDocumentMetadata metadata) async {
final confirmed = await showDeleteConfirmation(
context,
metadata: metadata,
);
if (confirmed != true) return;
try {
await syncRepo.deleteDocument(id: metadata.id);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Snapshot deleted')));
}
await refresh();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
}
}
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'${service.kind.displayName} Snapshots',
style: Theme.of(context).textTheme.titleSmall,
),
),
ListTile(
leading: busy.value
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_outlined),
title: const Text('Store Current'),
subtitle: Text(
'Encrypt and upload current ${service.kind.displayName.toLowerCase()}',
),
enabled: !busy.value,
onTap: storeCurrent,
),
if (loading.value)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: CircularProgressIndicator()),
)
else if (documents.value == null || documents.value!.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Text(
'No snapshots stored yet',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
else ...[
const Divider(height: 1),
for (final doc in documents.value!)
_DocumentTile(
metadata: doc,
busy: busy.value,
onRestore: () => restore(doc),
onEditLabel: () => editLabel(doc),
onDelete: () => delete(doc),
),
],
],
);
return SettingsContentCard(embedded: embedded, child: content);
}
}
class _DocumentTile extends StatelessWidget {
const _DocumentTile({
required this.metadata,
required this.busy,
required this.onRestore,
required this.onEditLabel,
required this.onDelete,
});
final SyncDocumentMetadata metadata;
final bool busy;
final VoidCallback onRestore;
final VoidCallback onEditLabel;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final title = metadata.label?.isNotEmpty == true
? metadata.label!
: 'Untitled';
final subtitle = StringBuffer(formatDateTime(metadata.updatedAt));
if (metadata.sourceDeviceId != null) {
subtitle.write(' · ${metadata.sourceDeviceId}');
}
return ListTile(
leading: const Icon(Icons.description_outlined),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
subtitle.toString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: MenuAnchor(
builder: (context, controller, _) => IconButton(
icon: const Icon(Icons.more_vert),
onPressed: busy
? null
: () =>
controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.cloud_download_outlined),
onPressed: onRestore,
child: const Text('Restore'),
),
MenuItemButton(
leadingIcon: const Icon(Icons.edit_outlined),
onPressed: onEditLabel,
child: const Text('Edit Label'),
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_outlined,
color: Theme.of(context).colorScheme.error,
),
onPressed: onDelete,
child: Text(
'Delete',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
}
}
/// Canonical secure_archive error message fragments that all map to
/// "the candidate key cannot open this blob" (wrong password, MAC failure,
/// corrupted ciphertext). Sourced from `secure_archive/lib/src/data/`:
///
/// - "wrong password or corrupted data" — from `secure_data.dart` (thrown
/// on `SecretBoxAuthenticationError` from chacha20-poly1305 MAC check).
/// - "Could not validate backup integrity" — from the archive layer.
/// - "Corrupt output" — from the streaming gzip decoder.
///
/// If `secure_archive` upstream switches to typed exceptions, prefer
/// catching the type and delete these strings.
const _secureArchiveDecryptionFailureFragments = <String>{
'wrong password or corrupted data',
'Could not validate backup integrity',
'Corrupt output',
};
/// True when [error] indicates the candidate sync key could not open the
/// snapshot — either it's wrong, the envelope is corrupted, or the format
/// version isn't recognised. Used to present a key/integrity problem
/// instead of a generic failure.
///
/// `FormatException` covers header-level failures (unsupported version,
/// bad framing), which `secure_archive` raises before any crypto runs.
/// Other `Exception` instances are sniffed by message contents — see
/// [_secureArchiveDecryptionFailureFragments] for the contract.
bool _isDecryptionFailure(Object error) {
if (error is FormatException) return true;
if (error is Exception) {
final message = error.toString();
return _secureArchiveDecryptionFailureFragments.any(message.contains);
}
return false;
}
@@ -0,0 +1,267 @@
/*
* 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:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:secure_archive/secure_archive.dart';
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
/// Lets a signed-in user derive an end-to-end encryption key from their
/// account password.
///
/// Two safety nets keep a mistyped password from silently locking the
/// user out of future restores:
///
/// 1. **Confirm-password field.** Same password must be typed twice; the
/// enable button stays disabled until the two fields match. This is
/// the only defence on the *first device* because there's nothing
/// remote to validate against yet.
/// 2. **Validation probe.** After the first device successfully enables
/// sync, a small encrypted canary is uploaded under
/// [SyncDocumentKind.syncValidationProbe]. On every *subsequent*
/// device, the probe is decrypted with the candidate key before the
/// key is persisted — wrong passwords are caught immediately rather
/// than silently breaking the next restore.
class SyncSetupCard extends HookConsumerWidget {
const SyncSetupCard({super.key, required this.email});
final String? email;
@override
Widget build(BuildContext context, WidgetRef ref) {
final passwordController = useTextEditingController();
final confirmController = useTextEditingController();
useListenable(passwordController);
useListenable(confirmController);
final busy = useState(false);
final error = useState<String?>(null);
final password = passwordController.text;
final confirm = confirmController.text;
final passwordsMatch = password.isNotEmpty && password == confirm;
Future<void> setupSyncKey() async {
if (password.isEmpty) {
error.value = 'Please enter your password';
return;
}
if (password != confirm) {
error.value = 'Passwords do not match';
return;
}
busy.value = true;
error.value = null;
try {
final syncKey = _deriveSyncKey(email: email, password: password);
// Validate the candidate key against any existing encrypted envelope
// before persisting. Without this, a wrong password is silently
// accepted, leaving the in-memory syncKey unable to decrypt future
// restores — and a "Set up sync" UX that looks successful is
// actively misleading.
final syncRepo = ref.read(accountSyncRepositoryProvider.notifier);
final probe = await _findValidationProbe(syncRepo);
if (probe != null) {
final ok = await _canDecrypt(probe.contentBlob, syncKey);
if (!ok) {
error.value =
'Password did not match your existing encrypted backups.';
return;
}
} else {
// First device on this account — nothing exists to validate
// against, so we leave a probe behind for the *next* device's
// setup to verify against. Failing the whole setup if the
// probe upload fails (rather than silently degrading to "no
// probe written") keeps the contract simple: if sync is
// enabled here, a probe exists on the server. The user can
// retry — probe upload is a single small insert and the most
// likely failure cause is transient network.
await _uploadValidationProbe(syncRepo, syncKey);
}
await ref
.read(accountAuthRepositoryProvider.notifier)
.setSyncKey(syncKey);
} catch (e) {
error.value = 'Failed to set up sync: $e';
} finally {
busy.value = false;
}
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.lock_outlined),
const SizedBox(width: 8),
Text(
'Set Up Encrypted Sync',
style: Theme.of(context).textTheme.titleSmall,
),
],
),
const SizedBox(height: 8),
Text(
'Enter your account password to enable end-to-end encrypted '
'sync. Your data is encrypted on-device before upload — '
'the server never sees your settings.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: 'Account Password',
border: const OutlineInputBorder(),
errorText: error.value,
),
obscureText: true,
enabled: !busy.value,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: confirmController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
border: OutlineInputBorder(),
),
obscureText: true,
enabled: !busy.value,
onSubmitted: (_) => setupSyncKey(),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: (busy.value || !passwordsMatch)
? null
: setupSyncKey,
child: busy.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enable Sync'),
),
),
],
),
);
}
}
/// Derives the sync key the rest of the app stores. The output is a
/// 64-char hex string fed to [SecureData] as a passphrase; the real
/// argon2id stretch happens inside [SecureData] every time it
/// encrypts/decrypts.
///
/// This HMAC step is not the cryptographic primitive — it just
/// canonicalises `(email, password)` into a fixed-length deterministic
/// string. Two devices reaching the same [syncKey] is necessary for
/// cross-device decrypt to work.
String _deriveSyncKey({required String? email, required String password}) {
final normalizedEmail = email?.toLowerCase() ?? '';
final hmac = Hmac(sha256, utf8.encode('weblibre-sync'));
final digest = hmac.convert(utf8.encode('$normalizedEmail:$password'));
return digest.toString();
}
/// Pick any existing remote envelope to verify the candidate sync key
/// against.
///
/// Checks the dedicated [SyncDocumentKind.syncValidationProbe] first so
/// every setup pays the same small-payload decrypt cost regardless of how
/// large the user's settings snapshots are. Falls back to scanning the
/// other kinds in declaration order if no probe is found — that covers
/// users who set up sync before probes were a thing.
Future<SyncDocumentResult?> _findValidationProbe(
AccountSyncRepository syncRepo,
) async {
final ordered = [
SyncDocumentKind.syncValidationProbe,
...SyncDocumentKind.values.where(
(k) => k != SyncDocumentKind.syncValidationProbe,
),
];
for (final kind in ordered) {
final docs = await syncRepo.listDocuments(kind: kind);
if (docs.isEmpty) continue;
final result = await syncRepo.fetchDocument(id: docs.first.id);
if (result != null) return result;
}
return null;
}
/// Encrypt a small canary payload under [syncKey] and upload it as a
/// [SyncDocumentKind.syncValidationProbe] document. Future devices fetch
/// this row in [_findValidationProbe] and refuse to persist a mismatching
/// candidate key.
///
/// The probe payload is intentionally tiny and version-tagged so a future
/// migration can recognise it without breaking older clients (they will
/// simply still decrypt-and-succeed, since the marker is opaque to them).
Future<void> _uploadValidationProbe(
AccountSyncRepository syncRepo,
String syncKey,
) async {
final payload = utf8.encode('weblibre:sync-probe:v1');
final secureData = SecureData(
argon2Params: Argon2Params.memoryConstrained(),
);
final ciphertext = await secureData.encrypt(payload, syncKey);
await syncRepo.storeDocument(
kind: SyncDocumentKind.syncValidationProbe,
schemaVersion: 1,
contentBlob: base64Encode(ciphertext),
label: 'sync validation probe',
);
}
Future<bool> _canDecrypt(String contentBlob, String syncKey) async {
try {
final encrypted = base64Decode(contentBlob);
final secureData = SecureData(
argon2Params: Argon2Params.memoryConstrained(),
);
await secureData.decrypt(encrypted, syncKey);
return true;
} catch (_) {
// Wrong password, corrupted envelope, or unsupported version — in
// all cases the candidate key is unusable for restoring this
// backup, so decline to persist it. The user can reset the sync
// key from the settings UI if their server-side data really is
// corrupted.
return false;
}
}