Add Supa account and search changes
This commit is contained in:
@@ -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}';
|
||||
}
|
||||
+255
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
+382
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user