improve backup naming and user experience

This commit is contained in:
otsmr 2026-07-30 11:44:02 +02:00
parent 3c8244b977
commit 753500ebfa
11 changed files with 561 additions and 465 deletions

View file

@ -1541,7 +1541,7 @@ abstract class AppLocalizations {
/// No description provided for @backupArchiveHeader.
///
/// In en, this message translates to:
/// **'Contacts, Settings and Messages'**
/// **'Contacts & Messages'**
String get backupArchiveHeader;
/// No description provided for @backupLastBackupDate.
@ -1595,7 +1595,7 @@ abstract class AppLocalizations {
/// No description provided for @backupSelectStrongPassword.
///
/// In en, this message translates to:
/// **'Choose a secure password. This is required if you want to restore your twonly Backup.'**
/// **'Choose a secure password. This is required if you want to restore your backup.'**
String get backupSelectStrongPassword;
/// No description provided for @password.
@ -1652,6 +1652,36 @@ abstract class AppLocalizations {
/// **'You can only change your password after you have authenticated!'**
String get backupChangePasswordAuthFailed;
/// No description provided for @backupFreeSpaceWithCloud.
///
/// In en, this message translates to:
/// **'Free Space with Cloud Backup'**
String get backupFreeSpaceWithCloud;
/// No description provided for @backupMemoriesNotEnabled.
///
/// In en, this message translates to:
/// **'Not enabled'**
String get backupMemoriesNotEnabled;
/// No description provided for @backupMemoriesUpgradeRequired.
///
/// In en, this message translates to:
/// **'Upgrade required'**
String get backupMemoriesUpgradeRequired;
/// No description provided for @todayAt.
///
/// In en, this message translates to:
/// **'Today at {time}'**
String todayAt(Object time);
/// No description provided for @yesterdayAt.
///
/// In en, this message translates to:
/// **'Yesterday at {time}'**
String yesterdayAt(Object time);
/// No description provided for @twonlySafeRecoverTitle.
///
/// In en, this message translates to:
@ -3671,7 +3701,7 @@ abstract class AppLocalizations {
/// No description provided for @passwordlessRecovery.
///
/// In en, this message translates to:
/// **'Passwordless Recovery'**
/// **'Password Recovery'**
String get passwordlessRecovery;
/// No description provided for @passwordlessRecoveryNotConfigured.
@ -4193,7 +4223,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsStorageContents.
///
/// In en, this message translates to:
/// **'Storage contents'**
/// **'Free up space'**
String get settingsStorageContents;
/// No description provided for @settingsStorageSortStorage.

View file

@ -817,7 +817,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get backupIdentityHeader => 'Identität';
@override
String get backupArchiveHeader => 'Kontakte, Einstellungen und Nachrichten';
String get backupArchiveHeader => 'Kontakte & Nachrichten';
@override
String get backupLastBackupDate => 'Letztes Backup';
@ -847,7 +847,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get backupSelectStrongPassword =>
'Wähle ein sicheres Passwort. Dies ist erforderlich, wenn du dein twonly Backup wiederherstellen möchtest.';
'Wähle ein sicheres Passwort. Dies ist erforderlich, wenn du dein Backup wiederherstellen möchtest.';
@override
String get password => 'Passwort';
@ -878,6 +878,26 @@ class AppLocalizationsDe extends AppLocalizations {
String get backupChangePasswordAuthFailed =>
'Du kannst dein Passwort nur ändern, wenn du dich authentifiziert hast!';
@override
String get backupFreeSpaceWithCloud =>
'Speicherplatz mit Cloud-Backup freigeben';
@override
String get backupMemoriesNotEnabled => 'Nicht aktiviert';
@override
String get backupMemoriesUpgradeRequired => 'Upgrade erforderlich';
@override
String todayAt(Object time) {
return 'Heute um $time';
}
@override
String yesterdayAt(Object time) {
return 'Gestern um $time';
}
@override
String get twonlySafeRecoverTitle => 'Backup wiederherstellen';
@ -2118,7 +2138,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get avatarCustomizeReset => 'Zurücksetzen';
@override
String get passwordlessRecovery => 'Passwortloses Backup';
String get passwordlessRecovery => 'Passwort vergessen';
@override
String get passwordlessRecoveryNotConfigured => 'Nicht konfiguriert';
@ -2184,7 +2204,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get passwordlessRecoveryEnableSuccess =>
'Passwortloses Backup erfolgreich aktiviert!';
'\"Passwort vergessen\" erfolgreich aktiviert!';
@override
String get passwordlessRecoveryEnterPin => 'Bitte gib eine PIN ein.';
@ -2194,13 +2214,16 @@ class AppLocalizationsDe extends AppLocalizations {
'Bitte gib eine E-Mail-Adresse ein.';
@override
String get passwordlessRecoveryEnableBtn => 'Passwortloses Backup aktivieren';
String get passwordlessRecoveryEnableBtn =>
'\"Passwort vergessen\" aktivieren';
@override
String get passwordlessRecoveryRecoverBtn => 'Passwortlos wiederherstellen';
String get passwordlessRecoveryRecoverBtn =>
'Mit \"Passwort vergessen\" wiederherstellen';
@override
String get passwordlessRecoveryModifyBtn => 'Passwortloses Backup bearbeiten';
String get passwordlessRecoveryModifyBtn =>
'\"Passwort vergessen\" bearbeiten';
@override
String passwordlessRecoveryStatusEnabled(num count) {
@ -2426,7 +2449,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsHelpNews => 'Neuigkeiten';
@override
String get settingsStorageContents => 'Speicherinhalte';
String get settingsStorageContents => 'Speicherplatz freigeben';
@override
String get settingsStorageSortStorage => 'Belegter Speicher';

View file

@ -812,7 +812,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get backupIdentityHeader => 'Identity';
@override
String get backupArchiveHeader => 'Contacts, Settings and Messages';
String get backupArchiveHeader => 'Contacts & Messages';
@override
String get backupLastBackupDate => 'Last backup';
@ -842,7 +842,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get backupSelectStrongPassword =>
'Choose a secure password. This is required if you want to restore your twonly Backup.';
'Choose a secure password. This is required if you want to restore your backup.';
@override
String get password => 'Password';
@ -873,6 +873,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get backupChangePasswordAuthFailed =>
'You can only change your password after you have authenticated!';
@override
String get backupFreeSpaceWithCloud => 'Free Space with Cloud Backup';
@override
String get backupMemoriesNotEnabled => 'Not enabled';
@override
String get backupMemoriesUpgradeRequired => 'Upgrade required';
@override
String todayAt(Object time) {
return 'Today at $time';
}
@override
String yesterdayAt(Object time) {
return 'Yesterday at $time';
}
@override
String get twonlySafeRecoverTitle => 'Restore backup';
@ -2104,7 +2123,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get avatarCustomizeReset => 'Reset';
@override
String get passwordlessRecovery => 'Passwordless Recovery';
String get passwordlessRecovery => 'Password Recovery';
@override
String get passwordlessRecoveryNotConfigured => 'Not configured';
@ -2405,7 +2424,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsHelpNews => 'News';
@override
String get settingsStorageContents => 'Storage contents';
String get settingsStorageContents => 'Free up space';
@override
String get settingsStorageSortStorage => 'Occupied storage';

@ -1 +1 @@
Subproject commit d08a6d4261135b80fba6a02f1e7e70fefcf17b8d
Subproject commit 659bfb084ea26d5471dea6c6d8e1c950c95ecea2

View file

@ -264,6 +264,28 @@ String formatDateTime(BuildContext context, DateTime? dateTime) {
}
}
String formatRelativeDateTime(BuildContext context, DateTime? dateTime) {
if (dateTime == null) {
return '-';
}
final now = clock.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final dateDay = DateTime(dateTime.year, dateTime.month, dateTime.day);
final time = DateFormat.Hm(
Localizations.localeOf(context).toLanguageTag(),
).format(dateTime);
if (dateDay == today) {
return context.lang.todayAt(time);
} else if (dateDay == yesterday) {
return context.lang.yesterdayAt(time);
} else {
return formatDateTime(context, dateTime);
}
}
String formatBytes(int bytes) {
if (bytes <= 0) return '0 Bytes';
const units = <String>['Bytes', 'KB', 'MB', 'GB', 'TB'];

View file

@ -75,7 +75,7 @@ class _BackupSetupPageState extends State<BackupSetupPage> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'twonly Backup',
context.lang.settingsBackup,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),

View file

@ -1,13 +1,23 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/memories_backup_detail.view.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
@ -21,6 +31,7 @@ class BackupView extends StatefulWidget {
class _BackupViewState extends State<BackupView> {
bool _isLoading = false;
CurrentBackupStatus? _backupStatus;
server.Response_MemoriesUsage? _memoriesUsage;
StreamSubscription<void>? _backupUpdateSub;
@override
@ -41,51 +52,29 @@ class _BackupViewState extends State<BackupView> {
Future<void> _loadBackupStatus() async {
setState(() => _isLoading = true);
final status = await BackupService.getData();
final memoriesUsage = await apiService.getMemoriesUsage();
if (!mounted) return;
setState(() {
_backupStatus = status;
_memoriesUsage = memoriesUsage;
_isLoading = false;
});
}
String _getBackupStatusString(LastBackupUploadState status) {
switch (status) {
case LastBackupUploadState.none:
return context.lang.backupPending;
case LastBackupUploadState.pending:
return context.lang.backupPending;
case LastBackupUploadState.failed:
return context.lang.backupFailed;
case LastBackupUploadState.success:
return context.lang.backupSuccess;
String _buildTileSubtitle(DateTime? date, int? size) {
if (date == null) return '-';
final dateStr = formatRelativeDateTime(context, date);
if (size != null && size > 0) {
return '$dateStr${formatBytes(size)}';
}
}
List<TableRow> _buildTableRows(List<(String, String)> rows) {
return rows.map((pair) {
return TableRow(
children: [
TableCell(
child: Text(pair.$1),
),
TableCell(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
),
child: Text(
pair.$2,
textAlign: TextAlign.right,
),
),
),
],
);
}).toList();
return dateStr;
}
@override
Widget build(BuildContext context) {
final currentPlan = context.watch<PurchasesProvider>().plan;
final isFreePlan = currentPlan == SubscriptionPlan.Free;
return StreamBuilder<void>(
stream: userService.onUserUpdated,
builder: (context, _) {
@ -94,100 +83,18 @@ class _BackupViewState extends State<BackupView> {
title: Text(context.lang.settingsBackup),
),
body: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(vertical: 16),
child: ListView(
children: [
const SizedBox(height: 8),
Text(
context.lang.backupTwonlySafeDesc,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (userService.currentUser.passwordLessRecovery != null)
const PasswordLessRecoveryStatus(),
if (userService.currentUser.isBackupEnabled)
Column(
children: [
const SizedBox(height: 32),
Center(
child: Text(
context.lang.backupIdentityHeader,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
Table(
defaultVerticalAlignment:
TableCellVerticalAlignment.middle,
children: _buildTableRows([
(
context.lang.backupLastBackupDate,
_backupStatus?.identityLastSuccessFull != null
? formatDateTime(
context,
_backupStatus!.identityLastSuccessFull,
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: PasswordLessRecoveryStatus(),
)
: '-',
),
(
context.lang.backupLastBackupSize,
_backupStatus?.identitySize != null
? formatBytes(_backupStatus!.identitySize!)
: '-',
),
(
context.lang.backupLastBackupResult,
_getBackupStatusString(
_backupStatus?.identityState ??
LastBackupUploadState.none,
),
),
]),
),
const SizedBox(height: 24),
Center(
child: Text(
context.lang.backupArchiveHeader,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
Table(
defaultVerticalAlignment:
TableCellVerticalAlignment.middle,
children: _buildTableRows([
(
context.lang.backupLastBackupDate,
_backupStatus?.archiveLastSuccessFull != null
? formatDateTime(
context,
_backupStatus!.archiveLastSuccessFull,
)
: '-',
),
(
context.lang.backupLastBackupSize,
_backupStatus?.archiveSize != null
? formatBytes(_backupStatus!.archiveSize!)
: '-',
),
(
context.lang.backupLastBackupResult,
_getBackupStatusString(
_backupStatus?.archiveState ??
LastBackupUploadState.none,
),
),
]),
),
],
),
if (userService.currentUser.passwordLessRecovery == null) ...[
const SizedBox(height: 20),
Center(
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
@ -195,8 +102,86 @@ class _BackupViewState extends State<BackupView> {
child: Text(context.lang.passwordlessRecoveryEnableBtn),
),
),
],
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
context.lang.backupTwonlySafeDesc,
textAlign: TextAlign.center,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 13,
),
),
),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 8),
if (userService.currentUser.isBackupEnabled) ...[
// 1. Identity tile
BetterListTile(
icon: FontAwesomeIcons.userCheck,
text: context.lang.backupIdentityHeader,
subtitle: Text(
_buildTileSubtitle(
_backupStatus?.identityLastSuccessFull,
_backupStatus?.identitySize,
),
),
),
// 2. Contacts & Messages tile
BetterListTile(
icon: FontAwesomeIcons.comments,
text: context.lang.backupArchiveHeader,
subtitle: Text(
_buildTileSubtitle(
_backupStatus?.archiveLastSuccessFull,
_backupStatus?.archiveSize,
),
),
),
// 3. Memories tile
BetterListTile(
icon: FontAwesomeIcons.photoFilm,
text: context.lang.memoriesBackupTitle,
subtitle: Text(
isFreePlan
? context.lang.backupMemoriesUpgradeRequired
: (!userService.currentUser.isCloudBackupEnabled
? context.lang.backupMemoriesNotEnabled
: (_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-')),
),
trailing: Icon(
Icons.chevron_right_rounded,
color: context.color.onSurfaceVariant,
),
onTap: () async {
if (isFreePlan) {
await context.push(Routes.settingsSubscription);
} else if (!userService
.currentUser
.isCloudBackupEnabled) {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
if (mounted) setState(() {});
unawaited(memoriesCloudService.checkUploads());
} else {
await context.navPush(const MemoriesBackupDetailView());
}
},
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 20),
],
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -212,9 +197,7 @@ class _BackupViewState extends State<BackupView> {
_isLoading = true;
});
await BackupService.makeBackup(force: true);
setState(() {
_isLoading = false;
});
await _loadBackupStatus();
},
child: Text(context.lang.backupTwonlySaveNow),
),

View file

@ -82,7 +82,7 @@ class _SetupBackupViewState extends State<SetupBackupView> {
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('twonly Backup'),
title: Text(context.lang.settingsBackup),
actions: [
IconButton(
onPressed: () => showBackupExplanation(context),
@ -127,12 +127,6 @@ class _SetupBackupViewState extends State<SetupBackupView> {
_repeatedController.text.isNotEmpty,
),
const SizedBox(height: 10),
Text(
context.lang.backupNoPasswordRecovery,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 10),
Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,

View file

@ -128,7 +128,7 @@ void showBackupExplanation(BuildContext context) {
),
const SizedBox(height: 24),
Text(
'twonly Backup',
context.lang.settingsBackup,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,

View file

@ -0,0 +1,312 @@
import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart';
class MemoriesBackupDetailView extends StatefulWidget {
const MemoriesBackupDetailView({super.key});
@override
State<MemoriesBackupDetailView> createState() =>
_MemoriesBackupDetailViewState();
}
class _MemoriesBackupDetailViewState extends State<MemoriesBackupDetailView> {
server.Response_MemoriesUsage? _memoriesUsage;
int _cloudOnlyCount = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
final memoriesUsage = await apiService.getMemoriesUsage();
final cloudOnlyCount = await twonlyDB.mediaFilesDao
.getCloudOnlyMemoriesCount();
if (mounted) {
setState(() {
_memoriesUsage = memoriesUsage;
_cloudOnlyCount = cloudOnlyCount;
_isLoading = false;
});
}
}
Future<void> _disableBackup() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.settingsStorageDisableBackupTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text.rich(
TextSpan(
children: formattedText(
context,
context.lang.settingsStorageDisableBackupBody(
_cloudOnlyCount,
),
textColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(
context.lang.settingsStorageDisableBackupBtn,
),
),
),
],
),
],
),
),
),
);
if (confirmed == true) {
try {
await apiService.disableMemoriesBackup();
final allMedias = await (twonlyDB.select(
twonlyDB.mediaFiles,
)..where((t) => t.stored.equals(true))).get();
for (final media in allMedias) {
final ms = MediaFileService(media);
if (!ms.storedPath.existsSync()) {
ms.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
}
}
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
cloudState: Value(CloudState.none),
),
);
await UserService.update((u) => u.isCloudBackupEnabled = false);
if (mounted) {
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
showSnackbar(context, e.toString());
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.memoriesBackupTitle),
),
body: _isLoading
? const Center(child: CircularProgressIndicator.adaptive())
: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Container(
height: 24,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
if (_memoriesUsage == null ||
_memoriesUsage!.maxBytes == 0) {
return const SizedBox.shrink();
}
final maxWidth = constraints.maxWidth;
final current = _memoriesUsage!.currentBytes.toDouble();
final max = _memoriesUsage!.maxBytes.toDouble();
final usageWidth =
((current / max).clamp(0.0, 1.0)) * maxWidth;
return Row(
children: [
if (usageWidth > 0)
Container(
width: usageWidth,
color: Colors.blue,
),
],
);
},
),
),
),
const SizedBox(height: 32),
StreamBuilder<MemoriesBackupProgress?>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
final isSyncing =
progress != null && progress.totalPending > 0;
final percent = isSyncing
? ((progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress /
progress.totalPending))
.clamp(0.0, 1.0)
: 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isSyncing) ...[
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: percent,
),
const SizedBox(height: 24),
],
Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
context.navPush(const StorageContentsView()),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.folder_open_outlined,
size: 18,
),
const SizedBox(width: 8),
Text(context.lang.settingsStorageContents),
],
),
),
),
const SizedBox(height: 16),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: isSyncing
? null
: () async {
final memories = await twonlyDB
.mediaFilesDao
.getMemoriesToBackup();
if (memories.isEmpty) {
if (context.mounted) {
showSnackbar(
context,
context
.lang
.settingsStorageSyncUpToDate,
level: SnackbarLevel.success,
);
}
} else {
unawaited(
memoriesCloudService.checkUploads(),
);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sync, size: 18),
const SizedBox(width: 8),
Text(context.lang.settingsStorageSyncNow),
],
),
),
),
const SizedBox(height: 16),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _disableBackup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 18),
const SizedBox(width: 8),
Text(
context
.lang
.settingsStorageDisableBackupAction,
),
],
),
),
),
],
);
},
),
],
),
);
}
}

View file

@ -1,21 +1,12 @@
import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart';
@ -28,8 +19,6 @@ class ManageStorageView extends StatefulWidget {
class _ManageStorageViewState extends State<ManageStorageView> {
Map<MediaType, int> _stats = {};
server.Response_MemoriesUsage? _memoriesUsage;
int _cloudOnlyCount = 0;
@override
void initState() {
@ -39,110 +28,13 @@ class _ManageStorageViewState extends State<ManageStorageView> {
Future<void> _loadStats() async {
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
final memoriesUsage = await apiService.getMemoriesUsage();
final cloudOnlyCount = await twonlyDB.mediaFilesDao
.getCloudOnlyMemoriesCount();
if (mounted) {
setState(() {
_stats = stats;
_memoriesUsage = memoriesUsage;
_cloudOnlyCount = cloudOnlyCount;
});
}
}
Future<void> _disableBackup() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.settingsStorageDisableBackupTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text.rich(
TextSpan(
children: formattedText(
context,
context.lang.settingsStorageDisableBackupBody(
_cloudOnlyCount,
),
textColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.settingsStorageDisableBackupBtn),
),
),
],
),
],
),
),
),
);
if (confirmed == true) {
try {
await apiService.disableMemoriesBackup();
final allMedias = await (twonlyDB.select(
twonlyDB.mediaFiles,
)..where((t) => t.stored.equals(true))).get();
for (final media in allMedias) {
final ms = MediaFileService(media);
if (!ms.storedPath.existsSync()) {
ms.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
}
}
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
cloudState: Value(CloudState.none),
),
);
await UserService.update((u) => u.isCloudBackupEnabled = false);
await _loadStats();
} catch (e) {
if (mounted) {
showSnackbar(context, e.toString());
}
}
}
}
@override
Widget build(BuildContext context) {
final currentPlan = context.watch<PurchasesProvider>().plan;
@ -162,7 +54,7 @@ class _ManageStorageViewState extends State<ManageStorageView> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isFreePlan) ...[
if (isFreePlan || !userService.currentUser.isCloudBackupEnabled) ...[
Card(
elevation: 0,
color: Theme.of(
@ -177,223 +69,44 @@ class _ManageStorageViewState extends State<ManageStorageView> {
),
),
child: InkWell(
onTap: () => context.push(Routes.settingsSubscription),
onTap: () {
if (isFreePlan) {
context.push(Routes.settingsSubscription);
} else {
context.push(Routes.settingsBackup);
}
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.cloud_off_outlined,
Icons.cloud_queue_rounded,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.settingsStorageNoCloudBackupTitle,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
child: Text(
context.lang.backupFreeSpaceWithCloud,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.lang.settingsStorageNoCloudBackupCard,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
),
),
),
),
const SizedBox(height: 24),
] else ...[
Text(
context.lang.memoriesBackupTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (!userService.currentUser.isCloudBackupEnabled) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.settingsStorageNoCloudBackupCard,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 16),
MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () async {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
setState(() {});
unawaited(memoriesCloudService.checkUploads());
},
child: Text(context.lang.enable),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
] else ...[
Text(
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Container(
height: 24,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
if (_memoriesUsage == null ||
_memoriesUsage!.maxBytes == 0) {
return const SizedBox.shrink();
}
final maxWidth = constraints.maxWidth;
final current = _memoriesUsage!.currentBytes.toDouble();
final max = _memoriesUsage!.maxBytes.toDouble();
final usageWidth =
((current / max).clamp(0.0, 1.0)) * maxWidth;
return Row(
children: [
if (usageWidth > 0)
Container(
width: usageWidth,
color: Colors.blue,
),
],
);
},
),
),
),
StreamBuilder<MemoriesBackupProgress?>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
final isSyncing =
progress != null && progress.totalPending > 0;
final percent = isSyncing
? ((progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress /
progress.totalPending))
.clamp(0.0, 1.0)
: 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isSyncing) ...[
const SizedBox(height: 12),
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: percent,
),
],
const SizedBox(height: 16),
Align(
child: MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: isSyncing
? null
: () async {
final memories = await twonlyDB.mediaFilesDao
.getMemoriesToBackup();
if (memories.isEmpty) {
if (context.mounted) {
showSnackbar(
context,
context
.lang
.settingsStorageSyncUpToDate,
);
}
} else {
unawaited(
memoriesCloudService.checkUploads(),
);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sync, size: 18),
const SizedBox(width: 8),
Text(context.lang.settingsStorageSyncNow),
],
),
),
),
const SizedBox(height: 12),
Align(
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _disableBackup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 18),
const SizedBox(width: 8),
Text(
context.lang.settingsStorageDisableBackupAction,
),
],
),
),
),
],
);
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
],
],
Text(
isFreePlan