first poc for e2ee backup

This commit is contained in:
otsmr 2026-07-19 23:05:50 +02:00
parent 96f8bc39d0
commit 498f5d7b72
20 changed files with 1277 additions and 586 deletions

View file

@ -2,6 +2,7 @@
## 0.4.0
- New: Encrypted Cloud Backup of Memories
- New: Passwordless Backup
- Fix: Performance issues

View file

@ -36,15 +36,15 @@ If you decide to give twonly a try, please keep in mind that it is still in its
- Open Source and can be downloaded directly from GitHub
- No email or phone number required to register
- The backend is hosted exclusively in Europe
- [Passwordless Backup](https://twonly.eu/en/blog/2026-passwordless-backup.html) without a phone number
- [User Discovery](https://twonly.eu/en/blog/2026-mutual-friends.html) without a phone number
- E2EE cloud backup of memories
## Roadmap
### Currently
- Focus on user-friendliness so that people enjoy using the app
- Passwordless recovery without a phone number
- Implementation of features so that Snapchat can actually be replaced
- E2EE cloud backup of memories
- Importing memories from Snapchat
### Next on the bucket list

View file

@ -1,8 +1,11 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
part 'mediafiles.dao.g.dart';
@ -217,11 +220,31 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
final rows = await select(mediaFiles).get();
final stats = <MediaType, int>{};
final Set<String> existingPaths;
if (rows.isNotEmpty) {
final dummyMs = MediaFileService(rows.first);
final storedDir = dummyMs.storedPath.parent;
if (storedDir.existsSync()) {
existingPaths = storedDir
.listSync()
.whereType<File>()
.map((f) => f.path)
.toSet();
} else {
existingPaths = <String>{};
}
} else {
existingPaths = <String>{};
}
for (final row in rows) {
final type = row.type;
final ms = MediaFileService(row);
if (existingPaths.contains(ms.storedPath.path)) {
final size = row.sizeInBytes ?? 0;
stats[type] = (stats[type] ?? 0) + size;
}
}
return stats;
}

View file

@ -1442,6 +1442,60 @@ abstract class AppLocalizations {
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
String deleteMemoriesBody(num count);
/// No description provided for @deleteMemoriesLocalOnly.
///
/// In en, this message translates to:
/// **'Local Only'**
String get deleteMemoriesLocalOnly;
/// No description provided for @deleteMemoriesCompletely.
///
/// In en, this message translates to:
/// **'Completely'**
String get deleteMemoriesCompletely;
/// No description provided for @memoriesSelectedCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1 {1 Element} other {{count} Elements}}'**
String memoriesSelectedCount(num count);
/// No description provided for @memoriesMenuSelectAll.
///
/// In en, this message translates to:
/// **'Select all'**
String get memoriesMenuSelectAll;
/// No description provided for @memoriesMenuDeselectAll.
///
/// In en, this message translates to:
/// **'Deselect all'**
String get memoriesMenuDeselectAll;
/// No description provided for @memoriesMenuExport.
///
/// In en, this message translates to:
/// **'Export to gallery'**
String get memoriesMenuExport;
/// No description provided for @memoriesMenuFavorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get memoriesMenuFavorite;
/// No description provided for @memoriesMenuDelete.
///
/// In en, this message translates to:
/// **'Delete completely'**
String get memoriesMenuDelete;
/// No description provided for @memoriesMenuDeleteLocal.
///
/// In en, this message translates to:
/// **'Delete locally'**
String get memoriesMenuDeleteLocal;
/// No description provided for @settingsBackup.
///
/// In en, this message translates to:
@ -4057,6 +4111,66 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Select Contacts'**
String get missingRecoveryContactsCardAction;
/// No description provided for @memoriesBackupLimitReached.
///
/// In en, this message translates to:
/// **'Cloud backup limit reached! Please upgrade your plan or free up space.'**
String get memoriesBackupLimitReached;
/// No description provided for @settingsStorageLocal.
///
/// In en, this message translates to:
/// **'Local storage'**
String get settingsStorageLocal;
/// No description provided for @settingsStorageNoCloudBackupTitle.
///
/// In en, this message translates to:
/// **'No Cloud Backup'**
String get settingsStorageNoCloudBackupTitle;
/// No description provided for @settingsStorageNoCloudBackupCard.
///
/// In en, this message translates to:
/// **'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.'**
String get settingsStorageNoCloudBackupCard;
/// No description provided for @galleryActionSave.
///
/// In en, this message translates to:
/// **'Save'**
String get galleryActionSave;
/// No description provided for @galleryActionExport.
///
/// In en, this message translates to:
/// **'Export'**
String get galleryActionExport;
/// No description provided for @galleryActionFavorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get galleryActionFavorite;
/// No description provided for @galleryActionUnfavorite.
///
/// In en, this message translates to:
/// **'Unfavorite'**
String get galleryActionUnfavorite;
/// No description provided for @galleryActionDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get galleryActionDelete;
/// No description provided for @galleryActionShare.
///
/// In en, this message translates to:
/// **'Share'**
String get galleryActionShare;
}
class _AppLocalizationsDelegate

View file

@ -664,7 +664,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
@override
String get familyFeature3 => '50GB Memories Speicher';
String get familyFeature3 => '50GB Memories Speicher';
@override
String get familyFeature4 => '✓ Flammen wiederherstellen';
@ -758,6 +758,41 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String get deleteMemoriesLocalOnly => 'Nur lokal';
@override
String get deleteMemoriesCompletely => 'Komplett';
@override
String memoriesSelectedCount(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Elemente',
one: '1 Element',
);
return '$_temp0';
}
@override
String get memoriesMenuSelectAll => 'Alles auswählen';
@override
String get memoriesMenuDeselectAll => 'Alle abwählen';
@override
String get memoriesMenuExport => 'In Gallery exportieren';
@override
String get memoriesMenuFavorite => 'Favorisieren';
@override
String get memoriesMenuDelete => 'Löschen';
@override
String get memoriesMenuDeleteLocal => 'Lokal löschen';
@override
String get settingsBackup => 'Backup';
@ -2348,4 +2383,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get missingRecoveryContactsCardAction => 'Kontakte auswählen';
@override
String get memoriesBackupLimitReached =>
'Cloud-Backup-Limit erreicht! Bitte aktualisiere dein Abonnement oder gib Speicherplatz frei.';
@override
String get settingsStorageLocal => 'Lokaler Speicherplatz';
@override
String get settingsStorageNoCloudBackupTitle => 'Kein Cloud-Backup';
@override
String get settingsStorageNoCloudBackupCard =>
'Sichere deine Erinnerungen in der Cloud, um lokalen Speicherplatz freizugeben und deine Bilder nicht zu verlieren. Alles Ende-zu-Ende verschlüsselt.';
@override
String get galleryActionSave => 'Sichern';
@override
String get galleryActionExport => 'Exportieren';
@override
String get galleryActionFavorite => 'Favorit';
@override
String get galleryActionUnfavorite => 'Favorit';
@override
String get galleryActionDelete => 'Löschen';
@override
String get galleryActionShare => 'Teilen';
}

View file

@ -753,6 +753,41 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0';
}
@override
String get deleteMemoriesLocalOnly => 'Local Only';
@override
String get deleteMemoriesCompletely => 'Completely';
@override
String memoriesSelectedCount(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Elements',
one: '1 Element',
);
return '$_temp0';
}
@override
String get memoriesMenuSelectAll => 'Select all';
@override
String get memoriesMenuDeselectAll => 'Deselect all';
@override
String get memoriesMenuExport => 'Export to gallery';
@override
String get memoriesMenuFavorite => 'Favorite';
@override
String get memoriesMenuDelete => 'Delete completely';
@override
String get memoriesMenuDeleteLocal => 'Delete locally';
@override
String get settingsBackup => 'Backup';
@ -2327,4 +2362,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get missingRecoveryContactsCardAction => 'Select Contacts';
@override
String get memoriesBackupLimitReached =>
'Cloud backup limit reached! Please upgrade your plan or free up space.';
@override
String get settingsStorageLocal => 'Local storage';
@override
String get settingsStorageNoCloudBackupTitle => 'No Cloud Backup';
@override
String get settingsStorageNoCloudBackupCard =>
'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.';
@override
String get galleryActionSave => 'Save';
@override
String get galleryActionExport => 'Export';
@override
String get galleryActionFavorite => 'Favorite';
@override
String get galleryActionUnfavorite => 'Unfavorite';
@override
String get galleryActionDelete => 'Delete';
@override
String get galleryActionShare => 'Share';
}

@ -1 +1 @@
Subproject commit 499ad4a7b703c1dff414f531c528894ad0c91799
Subproject commit 9d05713a1f69e7e4de742c414df7df6c4c7925a7

View file

@ -6,6 +6,7 @@ import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
@ -58,6 +59,8 @@ class ProgressMultipartRequest extends http.MultipartRequest {
}
class MemoriesCloudService {
static final Map<String, Mutex> _fileMutexes = {};
Timer? _timer;
bool _isProcessing = false;
@ -152,24 +155,38 @@ class MemoriesCloudService {
}
}
static Future<bool> downloadThumbnail(MediaFileService media) async {
final urls = await apiService.getMemoriesUrl(media.mediaFile.mediaId, true);
static Future<bool> downloadFromCloud(
MediaFileService media, {
required bool isThumbnail,
}) async {
final mediaId = media.mediaFile.mediaId;
final mutex = _fileMutexes.putIfAbsent(mediaId, Mutex.new);
return mutex.protect(() async {
final targetPath = isThumbnail ? media.thumbnailPath : media.storedPath;
if (targetPath.existsSync() && targetPath.lengthSync() > 0) {
return true;
}
final urls = await apiService.getMemoriesUrl(mediaId, isThumbnail);
if (urls == null || !urls.hasFullDownloadUrl()) return false;
try {
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
if (response.statusCode == 200) {
return await _decryptFile(response.bodyBytes, media.thumbnailPath);
return await _decryptFile(response.bodyBytes, targetPath);
} else {
Log.warn(
'Failed to download thumbnai statuscode ${response.statusCode}',
'Failed to download ${isThumbnail ? 'thumbnail' : 'full media'} statuscode ${response.statusCode}',
);
}
} catch (e) {
Log.warn(e);
}
return false;
});
}
Future<bool> _backupMemory(

View file

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
Future<bool?> showDeleteMemoriesDialog({
required BuildContext context,
required int count,
required bool hasCloudBackup,
}) {
return showDialog<bool>(
context: context,
builder: (context) => DeleteMemoriesDialog(
count: count,
hasCloudBackup: hasCloudBackup,
),
);
}
class DeleteMemoriesDialog extends StatelessWidget {
const DeleteMemoriesDialog({
required this.count,
required this.hasCloudBackup,
super.key,
});
final int count;
final bool hasCloudBackup;
@override
Widget build(BuildContext context) {
return 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.deleteImageTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text(
context.lang.deleteMemoriesBody(count),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
const SizedBox(height: 24),
if (hasCloudBackup) ...[
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.deleteMemoriesLocalOnly),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.deleteMemoriesCompletely),
),
),
],
),
const SizedBox(height: 8),
MyButton(
variant: MyButtonVariant.text,
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
] else ...[
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.delete),
),
),
],
),
],
],
),
),
);
}
}

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class SelectableThumbnailComp extends StatelessWidget {
const SelectableThumbnailComp({
@ -19,7 +19,7 @@ class SelectableThumbnailComp extends StatelessWidget {
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
decoration: BoxDecoration(
color: isSelected ? context.color.primary : Colors.transparent,
color: isSelected ? primaryColor : Colors.transparent,
boxShadow: const [
BoxShadow(
color: Colors.black12,
@ -49,9 +49,7 @@ class SelectableThumbnailComp extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? context.color.primary
: Colors.black38,
color: isSelected ? primaryColor : Colors.black38,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark

View file

@ -12,6 +12,7 @@ enum MyButtonVariant {
secondaryDense,
secondaryMiddle,
error,
errorMiddle,
}
class MyButton extends StatefulWidget {
@ -33,7 +34,6 @@ class MyButton extends StatefulWidget {
}
class _MyButtonState extends State<MyButton> {
@override
Widget build(BuildContext context) {
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
@ -189,6 +189,25 @@ class _MyButtonState extends State<MyButton> {
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.errorMiddle:
buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(
horizontal: 24,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
}
final childButton = widget.variant == MyButtonVariant.text

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -91,13 +92,23 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
if (!hasThumbnail) {
if (hasStored) {
media.createThumbnail();
unawaited(
media.createThumbnail().then((_) {
if (mounted) {
_resolveImage();
}
}),
);
} else {
MemoriesCloudService.downloadThumbnail(media).then((success) {
unawaited(
MemoriesCloudService.downloadFromCloud(media, isThumbnail: true).then(
(success) {
if (mounted && success) {
_resolveImage();
}
});
},
),
);
}
}
@ -117,6 +128,8 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
widget.galleryItem.mediaService.mediaFile.mediaId) {
_imageStream?.removeListener(_listener);
_imageStream = null;
_imageProvider = null;
_imageInfo = null;
_retries = 0;
_selectedImageFile = null;
@ -235,31 +248,44 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
],
),
),
if (media.mediaFile.cloudState == CloudState.pending)
const Positioned(
top: 6,
Builder(
builder: (context) {
final hasStored =
media.storedPath.existsSync() &&
media.storedPath.lengthSync() > 0;
final IconData iconData;
final Color color;
switch (media.mediaFile.cloudState) {
case CloudState.none:
iconData = Icons.cloud_off_outlined;
color = Colors.white54;
case CloudState.pending:
iconData = Icons.cloud_upload_outlined;
color = Colors.white70;
case CloudState.uploaded:
if (hasStored) {
iconData = Icons.cloud_done_outlined;
color = Colors.white;
} else {
iconData = Icons.cloud_outlined;
color = Colors.white;
}
}
return Positioned(
bottom: 6,
right: 6,
child: Icon(
Icons.cloud_upload_outlined,
color: Colors.white70,
iconData,
color: color,
size: 16,
shadows: [
Shadow(color: Colors.black54, blurRadius: 4),
],
),
)
else if (media.mediaFile.cloudState == CloudState.uploaded)
const Positioned(
top: 6,
right: 6,
child: Icon(
Icons.cloud_done_outlined,
color: Colors.white,
size: 16,
shadows: [
shadows: const [
Shadow(color: Colors.black54, blurRadius: 4),
],
),
);
},
),
],
),

View file

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class MemoriesSelectionMenuComp extends StatelessWidget {
const MemoriesSelectionMenuComp({
required this.areAllSelected,
required this.onSelectAll,
required this.onExport,
required this.onFavorite,
required this.onDeleteCompletely,
required this.onDeleteLocally,
super.key,
});
final bool areAllSelected;
final VoidCallback onSelectAll;
final VoidCallback onExport;
final VoidCallback onFavorite;
final VoidCallback onDeleteCompletely;
final VoidCallback onDeleteLocally;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: (val) {
switch (val) {
case 'selectAll':
onSelectAll();
case 'export':
onExport();
case 'favorite':
onFavorite();
case 'delete':
onDeleteCompletely();
case 'deleteLocal':
onDeleteLocally();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'selectAll',
child: Row(
children: [
Icon(
areAllSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
const SizedBox(width: 12),
Text(
areAllSelected
? context.lang.memoriesMenuDeselectAll
: context.lang.memoriesMenuSelectAll,
),
],
),
),
PopupMenuItem(
value: 'export',
child: Row(
children: [
const Icon(
Icons.file_download_outlined,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuExport),
],
),
),
PopupMenuItem(
value: 'favorite',
child: Row(
children: [
const Icon(
Icons.favorite_border,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuFavorite),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Icons.delete_forever_outlined,
size: 20,
color: Colors.redAccent,
),
const SizedBox(width: 12),
Text(
context.lang.memoriesMenuDelete,
style: const TextStyle(color: Colors.redAccent),
),
],
),
),
PopupMenuItem(
value: 'deleteLocal',
child: Row(
children: [
const Icon(
Icons.phonelink_erase_outlined,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuDeleteLocal),
],
),
),
],
);
}
}

View file

@ -1,130 +0,0 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class MemoriesSelectionToolbarComp extends StatelessWidget {
const MemoriesSelectionToolbarComp({
required this.selectedCount,
required this.areAllSelected,
required this.areAllFav,
required this.onSelectAll,
required this.onExport,
required this.onFavorite,
required this.onDelete,
required this.onClear,
super.key,
});
final int selectedCount;
final bool areAllSelected;
final bool areAllFav;
final VoidCallback onSelectAll;
final VoidCallback onExport;
final VoidCallback onFavorite;
final VoidCallback onDelete;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 24,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: context.color.surface.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 16,
offset: Offset(0, 4),
),
],
border: Border.all(
color: context.color.primary.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Text(
'$selectedCount',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
IconButton(
icon: Icon(
areAllSelected ? Icons.deselect : Icons.select_all,
size: 22,
),
onPressed: onSelectAll,
tooltip: areAllSelected
? context.lang.galleryDeselectAll
: context.lang.gallerySelectAll,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.file_download_outlined, size: 22),
onPressed: onExport,
tooltip: context.lang.galleryExport,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
areAllFav ? Icons.favorite : Icons.favorite_border,
size: 22,
color: areAllFav ? Colors.redAccent : null,
),
onPressed: onFavorite,
tooltip: areAllFav
? context.lang.galleryUnfavorite
: context.lang.galleryFavorite,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.delete_outline, size: 22),
onPressed: onDelete,
tooltip: context.lang.galleryDelete,
style: IconButton.styleFrom(
backgroundColor: Colors.redAccent.withValues(
alpha: 0.1,
),
foregroundColor: Colors.redAccent,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onClear,
tooltip: context.lang.galleryCancel,
),
],
),
),
);
}
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
const SynchronizedViewerActionsToolbarComp({
@ -27,15 +26,19 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 24,
left: 0,
right: 0,
return Padding(
padding: const EdgeInsets.all(12),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.50),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (showStoreButton) ...[
IconButton(
if (showStoreButton)
_ToolbarAction(
icon: isImageSaving
? const SizedBox(
width: 16,
@ -48,74 +51,101 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
: const FaIcon(
FontAwesomeIcons.floppyDisk,
color: Colors.white,
size: 20,
size: 18,
),
onPressed: isImageSaving ? null : onStore,
tooltip: 'Store media',
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
label: context.lang.galleryActionSave,
onTap: isImageSaving ? null : onStore,
),
),
const SizedBox(width: 16),
],
IconButton(
_ToolbarAction(
icon: const FaIcon(
FontAwesomeIcons.fileArrowDown,
FontAwesomeIcons.download,
color: Colors.white,
size: 21,
size: 19,
),
onPressed: onExport,
tooltip: context.lang.galleryExport,
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
label: context.lang.galleryActionExport,
onTap: onExport,
),
),
const SizedBox(width: 16),
IconButton(
_ToolbarAction(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.redAccent : Colors.white,
size: 24,
),
onPressed: onToggleFavorite,
tooltip: 'Favorite',
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 24,
),
onPressed: onDelete,
tooltip: context.lang.galleryDelete,
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
),
),
const SizedBox(width: 16),
IconButton(
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
color: primaryColor,
size: 22,
),
onPressed: onShare,
tooltip: context.lang.shareImagedEditorSendImage,
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
label: isFavorite
? context.lang.galleryActionUnfavorite
: context.lang.galleryActionFavorite,
onTap: onToggleFavorite,
),
_ToolbarAction(
icon: const Icon(
Icons.delete,
color: Colors.white,
size: 22,
),
label: context.lang.galleryActionDelete,
onTap: onDelete,
),
_ToolbarAction(
icon: const FaIcon(
FontAwesomeIcons.shareNodes,
color: Colors.white,
size: 19,
),
label: context.lang.galleryActionShare,
onTap: onShare,
),
],
),
),
);
}
}
class _ToolbarAction extends StatelessWidget {
const _ToolbarAction({
required this.icon,
required this.label,
required this.onTap,
});
final Widget icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Expanded(
child: Opacity(
opacity: onTap == null ? 0.4 : 1.0,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 24,
child: Center(child: icon),
),
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,162 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
class SynchronizedViewerItemComp extends StatefulWidget {
const SynchronizedViewerItemComp({
required this.item,
required this.currentlyViewedMediaIdNotifier,
required this.onZoomChanged,
super.key,
});
final MemoryItem item;
final ValueNotifier<String> currentlyViewedMediaIdNotifier;
final ValueChanged<bool> onZoomChanged;
@override
State<SynchronizedViewerItemComp> createState() =>
_SynchronizedViewerItemCompState();
}
class _SynchronizedViewerItemCompState
extends State<SynchronizedViewerItemComp> {
bool _isDownloading = false;
@override
void initState() {
super.initState();
_checkAndDownload();
}
@override
void didUpdateWidget(covariant SynchronizedViewerItemComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.item.mediaService.mediaFile.mediaId !=
widget.item.mediaService.mediaFile.mediaId) {
_checkAndDownload();
}
}
void _checkAndDownload() {
final media = widget.item.mediaService;
final hasStored =
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
if (!hasStored &&
media.mediaFile.cloudState == CloudState.uploaded &&
!_isDownloading) {
_isDownloading = true;
unawaited(
MemoriesCloudService.downloadFromCloud(
media,
isThumbnail: false,
).then((success) async {
if (success && mounted) {
final fullPath = media.storedPath;
if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) {
if (fullPath.existsSync()) {
try {
await precacheImage(FileImage(fullPath), context);
} catch (e) {
// Ignore precache errors
}
}
}
}
if (mounted) {
setState(() {
_isDownloading = false;
});
}
}),
);
}
}
@override
Widget build(BuildContext context) {
final item = widget.item;
final itemMediaId = item.mediaService.mediaFile.mediaId;
var filePath = item.mediaService.storedPath;
final hasStored = filePath.existsSync() && filePath.lengthSync() > 0;
if (!hasStored) {
filePath = item.mediaService.tempPath;
final hasTemp = filePath.existsSync() && filePath.lengthSync() > 0;
if (!hasTemp) {
filePath = item.mediaService.thumbnailPath;
}
}
final isVideo = item.mediaService.mediaFile.type == MediaType.video;
final hasVideoFile =
isVideo &&
(item.mediaService.storedPath.existsSync() &&
item.mediaService.storedPath.lengthSync() > 0 ||
item.mediaService.tempPath.existsSync() &&
item.mediaService.tempPath.lengthSync() > 0);
return Center(
child: ValueListenableBuilder<String>(
valueListenable: widget.currentlyViewedMediaIdNotifier,
builder: (context, activeMediaId, childWidget) {
final isActiveTarget = activeMediaId == itemMediaId;
if (isActiveTarget) {
return Hero(
tag: itemMediaId,
transitionOnUserGestures: true,
child: childWidget!,
);
}
return childWidget!;
},
child: !filePath.existsSync() || filePath.lengthSync() == 0
? item.mediaService.mediaFile.blurhash != null
? BlurHash(
hash: item.mediaService.mediaFile.blurhash!,
optimizationMode: BlurHashOptimizationMode.approximation,
)
: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
)
: hasVideoFile
? VideoPlayerFileHelper(videoPath: filePath)
: PhotoView(
key: ValueKey(filePath.path),
imageProvider: FileImage(filePath),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) {
final zoomed = state != PhotoViewScaleState.initial;
widget.onZoomChanged(zoomed);
},
),
),
);
}
}

View file

@ -7,11 +7,12 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/components/selection_toolbar.comp.dart';
import 'package:twonly/src/visual/views/memories/components/selection_menu.comp.dart';
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
class MemoriesView extends StatefulWidget {
@ -270,11 +271,13 @@ class MemoriesViewState extends State<MemoriesView>
}
}
Future<void> _batchDelete() async {
Future<void> _batchDelete({bool? forceDeleteCompletely}) async {
final count = _selectedMediaIds.length;
final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList();
var deleteCompletely = forceDeleteCompletely;
if (deleteCompletely == null) {
var hasCloudBackup = false;
for (final id in selectedList) {
final item = items
@ -287,40 +290,22 @@ class MemoriesViewState extends State<MemoriesView>
}
}
bool? deleteCompletely;
if (hasCloudBackup) {
deleteCompletely = await showDialog<bool>(
deleteCompletely = await showDeleteMemoriesDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.lang.deleteImageTitle),
content: Text(context.lang.deleteMemoriesBody(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Local Only'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Completely'),
),
],
),
count: count,
hasCloudBackup: hasCloudBackup,
);
} else {
} else if (deleteCompletely) {
final confirmed = await showAlertDialog(
context,
context.lang.deleteImageTitle,
context.lang.deleteMemoriesBody(count),
);
if (confirmed) deleteCompletely = true;
if (!confirmed) return;
}
if (deleteCompletely == null) return;
final isCompletely = deleteCompletely;
await _showProgressDialog(
'Deleting memories...',
@ -331,14 +316,16 @@ class MemoriesViewState extends State<MemoriesView>
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull;
if (item != null) {
if (deleteCompletely!) {
if (isCompletely) {
item.mediaService.fullMediaRemoval();
await apiService.deleteMemory(mediaId);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
} else {
if (item.mediaService.storedPath.existsSync()) {
item.mediaService.storedPath.deleteSync();
}
}
}
setProgress((i + 1) / selectedList.length);
}
},
@ -515,9 +502,9 @@ class MemoriesViewState extends State<MemoriesView>
vertical: 8,
horizontal: 16,
),
child: const Text(
'Cloud backup limit reached! Please upgrade your plan or free up space.',
style: TextStyle(
child: Text(
context.lang.memoriesBackupLimitReached,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
@ -565,15 +552,80 @@ class MemoriesViewState extends State<MemoriesView>
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
title: const Text(
'Memories',
style: TextStyle(fontWeight: FontWeight.bold),
leading: _selectionMode
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
setState(_selectedMediaIds.clear),
)
: null,
title: _selectionMode
? Builder(
builder: (context) => Text(
context.lang.memoriesSelectedCount(
_selectedMediaIds.length,
),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
)
: const Text(
'Memories',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
pinned: _selectionMode,
floating: true,
snap: true,
elevation: 0,
backgroundColor: context.color.surface,
actions: [
actions: _selectionMode
? [
IconButton(
icon: const Icon(
Icons.delete_outline,
),
onPressed: _batchDelete,
),
Builder(
builder: (context) {
final visibleCount =
_filterFavoritesOnly
? state.galleryItems
.where(
(e) => e
.mediaService
.mediaFile
.isFavorite,
)
.length
: state.galleryItems.length;
final areAllSelected =
visibleCount > 0 &&
_selectedMediaIds.length >=
visibleCount;
return MemoriesSelectionMenuComp(
areAllSelected: areAllSelected,
onSelectAll: _selectAll,
onExport: _batchExport,
onFavorite: _batchFavorite,
onDeleteCompletely: () =>
_batchDelete(
forceDeleteCompletely: true,
),
onDeleteLocally: () =>
_batchDelete(
forceDeleteCompletely:
false,
),
);
},
),
]
: [
if (state.isLoading)
Padding(
padding: const EdgeInsets.symmetric(
@ -589,7 +641,8 @@ class MemoriesViewState extends State<MemoriesView>
width: 20,
height: 20,
child: CircularProgressIndicator(
value: state.migrationProgress,
value:
state.migrationProgress,
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation(
@ -619,9 +672,6 @@ class MemoriesViewState extends State<MemoriesView>
!_filterFavoritesOnly;
});
},
tooltip: _filterFavoritesOnly
? 'Show all'
: 'Show favorites only',
),
],
),
@ -703,47 +753,6 @@ class MemoriesViewState extends State<MemoriesView>
);
},
),
if (_selectionMode)
Builder(
builder: (context) {
final items = _service.currentState.galleryItems;
var visibleCount = 0;
var favCount = 0;
for (final item in items) {
final isFav = item.mediaService.mediaFile.isFavorite;
if (!_filterFavoritesOnly || isFav) {
visibleCount++;
}
if (_selectedMediaIds.contains(
item.mediaService.mediaFile.mediaId,
)) {
if (isFav) {
favCount++;
}
}
}
final areAllSelected =
visibleCount > 0 &&
_selectedMediaIds.length >= visibleCount;
final areAllFav =
_selectedMediaIds.isNotEmpty &&
favCount == _selectedMediaIds.length;
return MemoriesSelectionToolbarComp(
selectedCount: _selectedMediaIds.length,
areAllSelected: areAllSelected,
areAllFav: areAllFav,
onSelectAll: _selectAll,
onExport: _batchExport,
onFavorite: _batchFavorite,
onDelete: _batchDelete,
onClear: () => setState(_selectedMediaIds.clear),
);
},
),
],
),
);

View file

@ -2,8 +2,6 @@ import 'dart:math';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
@ -11,11 +9,11 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_item.comp.dart';
class SynchronizedImageViewerScreen extends StatefulWidget {
const SynchronizedImageViewerScreen({
@ -68,7 +66,7 @@ class _SynchronizedImageViewerScreenState
if (item.mediaService.mediaFile.isFavorite) {
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
}
if (item.mediaService.storedPath.existsSync()) {
if (item.mediaService.mediaFile.stored) {
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
}
}
@ -157,18 +155,23 @@ class _SynchronizedImageViewerScreenState
}
Future<void> _deleteFile() async {
final confirmed = await showAlertDialog(
context,
context.lang.deleteImageTitle,
context.lang.deleteImageBody,
final item = widget.galleryItems[_currentIndex];
final mediaId = item.mediaService.mediaFile.mediaId;
final hasCloudBackup =
item.mediaService.mediaFile.cloudState != CloudState.none;
final deleteCompletely = await showDeleteMemoriesDialog(
context: context,
count: 1,
hasCloudBackup: hasCloudBackup,
);
if (!confirmed) return;
if (deleteCompletely == null) return;
widget.galleryItems[_currentIndex].mediaService.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId,
);
if (deleteCompletely) {
item.mediaService.fullMediaRemoval();
await apiService.deleteMemory(mediaId);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
widget.galleryItems.removeAt(_currentIndex);
@ -187,6 +190,11 @@ class _SynchronizedImageViewerScreenState
widget.activeMediaIdNotifier.value = newId;
setState(() {});
} else {
if (item.mediaService.storedPath.existsSync()) {
item.mediaService.storedPath.deleteSync();
}
}
}
Future<void> _exportFile() async {
@ -308,100 +316,51 @@ class _SynchronizedImageViewerScreenState
widget.activeMediaIdNotifier.value = newMediaId;
},
itemBuilder: (context, index) {
final item = widget.galleryItems[index];
final itemMediaId = item.mediaService.mediaFile.mediaId;
var filePath = item.mediaService.storedPath;
if (!filePath.existsSync()) {
filePath = item.mediaService.tempPath;
if (!filePath.existsSync()) {
filePath = item.mediaService.thumbnailPath;
}
}
final isVideo =
item.mediaService.mediaFile.type == MediaType.video;
return Center(
child: ValueListenableBuilder<String>(
valueListenable: _currentlyViewedMediaIdNotifier,
builder: (context, activeMediaId, childWidget) {
// Dynamically resolve Hero tags to prevent layout tree duplicate assertions
final isActiveTarget = activeMediaId == itemMediaId;
if (isActiveTarget) {
return Hero(
tag: itemMediaId,
transitionOnUserGestures: true,
child: childWidget!,
);
}
return childWidget!;
},
child: !filePath.existsSync()
? item.mediaService.mediaFile.blurhash != null
? BlurHash(
hash: item
.mediaService
.mediaFile
.blurhash!,
optimizationMode:
BlurHashOptimizationMode
.approximation,
)
: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
)
: isVideo
? VideoPlayerFileHelper(videoPath: filePath)
: PhotoView(
imageProvider: FileImage(filePath),
initialScale:
PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale:
PhotoViewComputedScale.covered * 4.1,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
return SynchronizedViewerItemComp(
item: widget.galleryItems[index],
currentlyViewedMediaIdNotifier:
_currentlyViewedMediaIdNotifier,
onZoomChanged: (zoomed) {
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
),
),
);
},
),
SynchronizedViewerActionsToolbarComp(
isFavorite: _favoritedMediaIds.contains(currentMediaId),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 16,
left: 0,
right: 0,
child: AnimatedOpacity(
opacity: _isZoomed ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: IgnorePointer(
ignoring: _isZoomed,
child: Center(
child: SynchronizedViewerActionsToolbarComp(
isFavorite: _favoritedMediaIds.contains(
currentMediaId,
),
onShare: _shareMediaFile,
onExport: _exportFile,
onToggleFavorite: () => _toggleFavorite(currentMediaId),
onToggleFavorite: () =>
_toggleFavorite(currentMediaId),
onDelete: _deleteFile,
showStoreButton: !_storedMediaIds.contains(currentMediaId),
showStoreButton: !_storedMediaIds.contains(
currentMediaId,
),
onStore: _storeMediaFile,
isImageSaving: _isSaving,
),
),
),
),
),
],
),
],

View file

@ -5,10 +5,7 @@ import 'package:go_router/go_router.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/services/backup.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart';
@ -24,7 +21,6 @@ class BackupView extends StatefulWidget {
class _BackupViewState extends State<BackupView> {
bool _isLoading = false;
CurrentBackupStatus? _backupStatus;
server.Response_MemoriesUsage? _memoriesUsage;
StreamSubscription<void>? _backupUpdateSub;
@override
@ -45,11 +41,9 @@ 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;
});
}
@ -188,58 +182,6 @@ class _BackupViewState extends State<BackupView> {
),
]),
),
const SizedBox(height: 24),
const Center(
child: Text(
'Memories Backup',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
Table(
defaultVerticalAlignment:
TableCellVerticalAlignment.middle,
children: _buildTableRows([
(
'Usage',
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
),
(
'Files Backed Up',
_memoriesUsage != null
? '${_memoriesUsage!.count}'
: '-',
),
]),
),
StreamBuilder<MemoriesBackupProgress>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
if (progress == null || progress.totalPending == 0) {
return const SizedBox.shrink();
}
final percent = (progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress / progress.totalPending);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.clamp(0.0, 1.0),
),
],
);
},
),
],
),
@ -250,7 +192,7 @@ class _BackupViewState extends State<BackupView> {
variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
context.navPush(const PasswordLessRecoverySetup()),
child: const Text('Setup Passwordless Recovery'),
child: Text(context.lang.passwordlessRecoveryEnableBtn),
),
),
],

View file

@ -1,6 +1,14 @@
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/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
class ManageStorageView extends StatefulWidget {
@ -12,6 +20,7 @@ class ManageStorageView extends StatefulWidget {
class _ManageStorageViewState extends State<ManageStorageView> {
Map<MediaType, int> _stats = {};
server.Response_MemoriesUsage? _memoriesUsage;
@override
void initState() {
@ -21,15 +30,20 @@ class _ManageStorageViewState extends State<ManageStorageView> {
Future<void> _loadStats() async {
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
final memoriesUsage = await apiService.getMemoriesUsage();
if (mounted) {
setState(() {
_stats = stats;
_memoriesUsage = memoriesUsage;
});
}
}
@override
Widget build(BuildContext context) {
final currentPlan = context.watch<PurchasesProvider>().plan;
final isFreePlan = currentPlan == SubscriptionPlan.Free;
final totalBytes = _stats.entries
.where((e) => e.key != MediaType.audio)
.fold<int>(0, (a, b) => a + b.value);
@ -43,9 +57,149 @@ class _ManageStorageViewState extends State<ManageStorageView> {
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isFreePlan) ...[
Card(
elevation: 0,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: InkWell(
onTap: () => context.push(Routes.settingsSubscription),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.cloud_off_outlined,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.settingsStorageUsed,
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,
),
),
],
),
),
],
),
),
),
),
const SizedBox(height: 24),
] else ...[
Text(
'Memories Backup',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
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;
if (progress == null || progress.totalPending == 0) {
return const SizedBox.shrink();
}
final percent =
(progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress / progress.totalPending);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.clamp(0.0, 1.0),
),
],
);
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
],
Text(
isFreePlan
? context.lang.settingsStorageUsed
: context.lang.settingsStorageLocal,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),