diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index b34c2438..27e1e730 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -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. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 30079292..22e2e94c 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -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'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 64a5bfdc..f5f04aa9 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -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'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index d08a6d42..659bfb08 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit d08a6d4261135b80fba6a02f1e7e70fefcf17b8d +Subproject commit 659bfb084ea26d5471dea6c6d8e1c950c95ecea2 diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index a92c8bdf..58387db0 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -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 = ['Bytes', 'KB', 'MB', 'GB', 'TB']; diff --git a/lib/src/visual/views/onboarding/setup/backup.setup.dart b/lib/src/visual/views/onboarding/setup/backup.setup.dart index 5fb49224..1a2f0cd1 100644 --- a/lib/src/visual/views/onboarding/setup/backup.setup.dart +++ b/lib/src/visual/views/onboarding/setup/backup.setup.dart @@ -75,7 +75,7 @@ class _BackupSetupPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'twonly Backup', + context.lang.settingsBackup, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index b2c62163..3487b821 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -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 { bool _isLoading = false; CurrentBackupStatus? _backupStatus; + server.Response_MemoriesUsage? _memoriesUsage; StreamSubscription? _backupUpdateSub; @override @@ -41,51 +52,29 @@ class _BackupViewState extends State { Future _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 _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().plan; + final isFreePlan = currentPlan == SubscriptionPlan.Free; + return StreamBuilder( stream: userService.onUserUpdated, builder: (context, _) { @@ -94,109 +83,105 @@ class _BackupViewState extends State { 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 Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: PasswordLessRecoveryStatus(), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: () => + context.navPush(const PasswordLessRecoverySetup()), + child: Text(context.lang.passwordlessRecoveryEnableBtn), ), - const SizedBox(height: 8), - Table( - defaultVerticalAlignment: - TableCellVerticalAlignment.middle, - children: _buildTableRows([ - ( - context.lang.backupLastBackupDate, - _backupStatus?.identityLastSuccessFull != null - ? formatDateTime( - context, - _backupStatus!.identityLastSuccessFull, - ) - : '-', - ), - ( - 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( - child: MyButton( - variant: MyButtonVariant.primaryMiddle, - onPressed: () => - context.navPush(const PasswordLessRecoverySetup()), - 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 { _isLoading = true; }); await BackupService.makeBackup(force: true); - setState(() { - _isLoading = false; - }); + await _loadBackupStatus(); }, child: Text(context.lang.backupTwonlySaveNow), ), diff --git a/lib/src/visual/views/settings/backup/backup_setup.view.dart b/lib/src/visual/views/settings/backup/backup_setup.view.dart index bf19d729..7a8d4277 100644 --- a/lib/src/visual/views/settings/backup/backup_setup.view.dart +++ b/lib/src/visual/views/settings/backup/backup_setup.view.dart @@ -82,7 +82,7 @@ class _SetupBackupViewState extends State { 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 { _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, diff --git a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart index c5fcc189..925d401c 100644 --- a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart +++ b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart @@ -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, diff --git a/lib/src/visual/views/settings/backup/memories_backup_detail.view.dart b/lib/src/visual/views/settings/backup/memories_backup_detail.view.dart new file mode 100644 index 00000000..b0c6b761 --- /dev/null +++ b/lib/src/visual/views/settings/backup/memories_backup_detail.view.dart @@ -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 createState() => + _MemoriesBackupDetailViewState(); +} + +class _MemoriesBackupDetailViewState extends State { + server.Response_MemoriesUsage? _memoriesUsage; + int _cloudOnlyCount = 0; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + final memoriesUsage = await apiService.getMemoriesUsage(); + final cloudOnlyCount = await twonlyDB.mediaFilesDao + .getCloudOnlyMemoriesCount(); + if (mounted) { + setState(() { + _memoriesUsage = memoriesUsage; + _cloudOnlyCount = cloudOnlyCount; + _isLoading = false; + }); + } + } + + Future _disableBackup() async { + final confirmed = await showDialog( + 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( + 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, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart b/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart index 0af5074a..da28a317 100644 --- a/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart +++ b/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart @@ -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 { Map _stats = {}; - server.Response_MemoriesUsage? _memoriesUsage; - int _cloudOnlyCount = 0; @override void initState() { @@ -39,110 +28,13 @@ class _ManageStorageViewState extends State { Future _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 _disableBackup() async { - final confirmed = await showDialog( - 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().plan; @@ -162,7 +54,7 @@ class _ManageStorageViewState extends State { 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 { ), ), 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( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - context.lang.settingsStorageNoCloudBackupCard, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], + child: Text( + context.lang.backupFreeSpaceWithCloud, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), + 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( - 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), - ], + const Divider(), + const SizedBox(height: 24), ], Text( isFreePlan