Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 175 additions & 25 deletions lib/src/widgets/solid_backup_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ import 'dart:typed_data';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show SystemNavigator;

import 'package:file_picker/file_picker.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:gap/gap.dart';
import 'package:markdown_tooltip/markdown_tooltip.dart';
import 'package:solidpod/solidpod.dart'
show SecurityKeyVerificationException, isUserLoggedIn;
import 'package:universal_io/io.dart' show File;
import 'package:universal_io/io.dart' show File, Platform, exit;

import 'package:solidui/src/services/solid_backup_service.dart';
import 'package:solidui/src/widgets/secret_text_field.dart';
Expand All @@ -50,6 +51,8 @@ import 'package:solidui/src/widgets/secret_text_field.dart';
/// it as a single compressed, encrypted backup file on this device.
/// * **Import** — restore a previously exported backup (from this POD or
/// another), re-encrypting the data with this POD's current security key.
/// The user is warned beforehand, and afterwards the app is closed, so
/// that reopening it reloads the restored data in full.
///
/// The dialog mirrors the layout and wording of the TodoPod Backup feature but
/// works at the level of the whole app data folder rather than a single model.
Expand Down Expand Up @@ -189,6 +192,16 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {
return;
}

// Warn about the restart before the user picks a file or types any keys.
// The app keeps the data it loaded at start-up in memory, so it must be
// restarted once the restore has finished.

if (!mounted) return;
if (!await _confirmRestore()) {
_setImportMessage('Import cancelled.');
return;
}

setState(() {
_busy = true;
_importMessage = null;
Expand Down Expand Up @@ -238,7 +251,7 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {
'file${header.fileCount == 1 ? '' : 's'}. Enter the original '
'security key it was created with, and the current security key '
'for this POD. Restoring overwrites the contents of this app\'s '
'data folder.',
'data folder, after which the app closes and you open it again.',
fields: const [
(key: 'originalKey', label: 'Original Security Key'),
(key: 'currentKey', label: 'Current Security Key'),
Expand All @@ -257,11 +270,17 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {

final skippedNote =
result.skipped.isEmpty ? '' : ' (${result.skipped.length} skipped)';
_setImportMessage(
'Restored ${result.restoredCount} '
'file${result.restoredCount == 1 ? '' : 's'}$skippedNote.',
error: result.skipped.isNotEmpty,
);
final summary = 'Restored ${result.restoredCount} '
'file${result.restoredCount == 1 ? '' : 's'}$skippedNote.';
_setImportMessage(summary, error: result.skipped.isNotEmpty);

// The POD now holds the restored data, but this app is still running
// with whatever it loaded beforehand. Insist on a restart so that the
// data is reloaded in full and nothing stale is written back.

if (!mounted) return;
await _showRestartRequiredDialog(summary);
if (mounted) Navigator.of(context).pop();
} on BackupAppMismatchException catch (e) {
_setImportMessage(
'This backup was created by a different application '
Expand All @@ -279,6 +298,126 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {
}
}

// Restart prompts.

// Ask the user to acknowledge, before anything is written, that the app has
// to be started again once the restore has finished. Returns true to go
// ahead.

Future<bool> _confirmRestore() async {
final proceed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_outlined),
SizedBox(width: 12),
Expanded(child: Text('Restart Required')),
],
),
content: const SizedBox(
width: 420,
child: Text(
'Restoring a backup replaces the contents of this app\'s data '
'folder on your POD.\n\n'
'The app has to start again as soon as the restore has finished, '
'so that it reloads all of the restored data. Carrying on without '
'it would write your changes on top of the restored data and may '
'leave it inconsistent, so '
'${kIsWeb ? 'you will need to reload the page yourself' : 'the app will close and you will need to open it again yourself'} '
'once the restore is done.\n\n'
'Do you want to continue?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Continue'),
),
],
),
);
return proceed ?? false;
}

// Tell the user that the restore is done and the app has to be started
// again, and close it for them. The dialog offers the single action, and
// cannot be dismissed by tapping outside it or by the system back gesture,
// so the app is never left running on top of freshly restored data.
//
// An app cannot reliably start itself again, so the user is told plainly to
// open it again by hand once it has closed.

Future<void> _showRestartRequiredDialog(String summary) => showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => PopScope(
canPop: false,
child: AlertDialog(
title: const Row(
children: [
Icon(kIsWeb ? Icons.refresh : Icons.exit_to_app),
SizedBox(width: 12),
Expanded(
child: Text(kIsWeb ? 'Reload Required' : 'Close and Reopen'),
),
],
),
content: SizedBox(
width: 420,
child: Text(
'$summary\n\n'
'The restored data is now on your POD, but the app is still '
'showing the data it loaded before the restore. It has to '
'start again before you carry on, or the data you have just '
'restored may be overwritten.\n\n'
'${kIsWeb ? 'Please reload this page now to see the restored '
'data.' : 'The app will close now. Please open it again '
'manually to see the restored data.'}',
),
),
actions: [
FilledButton.icon(
icon: const Icon(kIsWeb ? Icons.refresh : Icons.exit_to_app),
label: const Text(kIsWeb ? 'OK' : 'Close App'),
onPressed: () {
// Close the dialogs first. If the platform declines to close
// the app, the user is at least left with it usable and the
// banner still explaining that a restart is needed.

Navigator.of(dialogContext).pop();
_closeApp();
},
),
],
),
),
);

// Close the app as firmly as the platform allows.
//
// On desktop the process is ended outright. That is deliberate: the data
// underneath the app has just been replaced, and nothing the app still holds
// in memory should get the chance to be written back. On Android the system
// is asked to close the app, which finishes the activity in the ordinary
// way; Apple does not let an iOS app close itself, so there this is
// best-effort. A browser tab cannot close itself either, so on the web
// nothing happens here and the dialog asks the user to reload the page.

void _closeApp() {
if (kIsWeb) return;

if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
exit(0);
}

SystemNavigator.pop();
}

// Key prompt.

// Show a modal form of one or more masked security-key fields. Returns a map
Expand All @@ -296,24 +435,33 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: FormBuilder(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const Gap(16),
for (final field in fields) ...[
SecretTextField(
fieldKey: field.key,
fieldLabel: field.label,
validateFunc: (value) =>
value.isEmpty ? 'Please enter ${field.label}.' : null,
),
const Gap(8),

// Cap the key fields at a readable width. Without this the dialog
// grows with the window and the fields end up far wider than any
// security key needs.

content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: SingleChildScrollView(
child: FormBuilder(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const Gap(16),
for (final field in fields) ...[
SecretTextField(
fieldKey: field.key,
fieldLabel: field.label,
validateFunc: (value) => value.isEmpty
? 'Please enter ${field.label}.'
: null,
),
const Gap(8),
],
],
],
),
),
),
),
Expand Down Expand Up @@ -409,7 +557,9 @@ class _SolidBackupDialogState extends State<SolidBackupDialog> {
'Restore a backup created by this application. The backup '
'is decrypted with its original security key and '
're-encrypted with this POD\'s current security key, '
'overwriting the data folder\'s contents.',
'overwriting the data folder\'s contents.\n\n'
'The app closes once the restore has finished, and you '
'then open it again manually.',
child: OutlinedButton.icon(
icon: const Icon(Icons.upload),
label: const Text('Import Backup'),
Expand Down
51 changes: 22 additions & 29 deletions lib/src/widgets/solid_login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -382,19 +382,7 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
// callback; performContinue() skips verification when the folder list
// is empty, so ensure they are populated before the check.

if (defaultFolders.isEmpty) {
await setAppDirName(widget.appDirectory);
final folders = await generateDefaultFolders();
final files = await generateDefaultFiles();
final customFolders = generateCustomFolders(
widget.customFolderPathList,
);
if (!mounted) return;
setState(() {
defaultFolders = folders + customFolders;
defaultFiles = files;
});
}
if (defaultFolders.isEmpty) await _loadDefaultStructure();

if (!mounted) return;
setState(() => _checkingAutoLogin = false);
Expand Down Expand Up @@ -446,13 +434,22 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
void _onThemeChanged() => mounted ? setState(() {}) : null;
bool get isDarkMode => SolidLoginThemeHelper.isDarkMode(context);

/// The colours in force, chosen by the current light or dark mode.

SolidLoginThemeMode get _currentTheme =>
isDarkMode ? widget.themeConfig.darkTheme : widget.themeConfig.lightTheme;

Future<void> _initTheme() async {
await solidThemeNotifier.initialize();
if (mounted) setState(() {});
}

Future<void> _initPackageInfo() async {
if (!mounted) return;
/// Populate the default folder and file lists for the app's data folder.
///
/// Shared by [_initPackageInfo] and the auto-login check, which needs the
/// lists in place before it can verify the POD structure.

Future<void> _loadDefaultStructure() async {
await setAppDirName(widget.appDirectory);
final folders = await generateDefaultFolders();
final files = await generateDefaultFiles();
Expand All @@ -462,6 +459,11 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
defaultFolders = folders + customFolders;
defaultFiles = files;
});
}

Future<void> _initPackageInfo() async {
if (!mounted) return;
await _loadDefaultStructure();
final appInfo = await getAppNameVersion();
if (!mounted) return;
setState(() {
Expand Down Expand Up @@ -493,14 +495,11 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
Duration? duration,
bool showAction = true,
}) {
final currentTheme = isDarkMode
? widget.themeConfig.darkTheme
: widget.themeConfig.lightTheme;
SolidLoginSnackbarHelper.showSnackbar(
context,
message: message,
isDarkMode: isDarkMode,
currentTheme: currentTheme,
currentTheme: _currentTheme,
snackbarConfig: widget.snackbarConfig,
duration: duration,
showAction: showAction,
Expand All @@ -518,12 +517,6 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}

// Use the internal state for theme instead of system brightness.

final currentTheme = isDarkMode
? widget.themeConfig.darkTheme
: widget.themeConfig.lightTheme;

// Use resolved image with fallback support.

final effectiveImage = _resolvedImage ?? SolidConfig.soliduiDefaultImage;
Expand Down Expand Up @@ -620,13 +613,13 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
setState(() => _staySignedIn = newValue);
SolidLoginAuthHandler.setStaySignedIn(newValue);
},
textColor: currentTheme.textColor,
textColor: _currentTheme.textColor,
);

final tryAnotherAccountButton =
SolidLoginBuildHelper.buildTryAnotherAccountButton(
onPressed: performTryAnotherAccount,
textColor: currentTheme.textColor,
textColor: _currentTheme.textColor,
);

// Build the login panel content.
Expand All @@ -649,7 +642,7 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
if (widget.infoButtonStyle.visible) infoButton,
],
isRequired: widget.required,
currentTheme: currentTheme,
currentTheme: _currentTheme,
serverInputFocusNode: _serverInputFocusNode,
onServerSubmitted: performLogin,
staySignedInCheckbox: staySignedInCheckbox,
Expand All @@ -665,7 +658,7 @@ class _SolidLoginState extends State<SolidLogin> with WidgetsBindingObserver {
final loginPanel = SolidLoginPanel.buildCompletePanel(
context: context,
panelDecor: loginPanelDecor,
currentTheme: currentTheme,
currentTheme: _currentTheme,
);

return SolidLoginBuildHelper.buildScaffold(
Expand Down
Loading