mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 10:24:08 +00:00
first poc for e2ee backup
This commit is contained in:
parent
96f8bc39d0
commit
498f5d7b72
20 changed files with 1277 additions and 586 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
|
- New: Encrypted Cloud Backup of Memories
|
||||||
- New: Passwordless Backup
|
- New: Passwordless Backup
|
||||||
- Fix: Performance issues
|
- Fix: Performance issues
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- Open Source and can be downloaded directly from GitHub
|
||||||
- No email or phone number required to register
|
- No email or phone number required to register
|
||||||
- The backend is hosted exclusively in Europe
|
- 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
|
## Roadmap
|
||||||
|
|
||||||
### Currently
|
### 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
|
- Implementation of features so that Snapchat can actually be replaced
|
||||||
- E2EE cloud backup of memories
|
|
||||||
- Importing memories from Snapchat
|
- Importing memories from Snapchat
|
||||||
|
|
||||||
### Next on the bucket list
|
### Next on the bucket list
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:hashlib/random.dart';
|
import 'package:hashlib/random.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.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';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
part 'mediafiles.dao.g.dart';
|
part 'mediafiles.dao.g.dart';
|
||||||
|
|
@ -217,10 +220,30 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
final rows = await select(mediaFiles).get();
|
final rows = await select(mediaFiles).get();
|
||||||
final stats = <MediaType, int>{};
|
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) {
|
for (final row in rows) {
|
||||||
final type = row.type;
|
final type = row.type;
|
||||||
final size = row.sizeInBytes ?? 0;
|
final ms = MediaFileService(row);
|
||||||
stats[type] = (stats[type] ?? 0) + size;
|
if (existingPaths.contains(ms.storedPath.path)) {
|
||||||
|
final size = row.sizeInBytes ?? 0;
|
||||||
|
stats[type] = (stats[type] ?? 0) + size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
|
|
|
||||||
|
|
@ -1442,6 +1442,60 @@ abstract class AppLocalizations {
|
||||||
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
||||||
String deleteMemoriesBody(num count);
|
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.
|
/// No description provided for @settingsBackup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -4057,6 +4111,66 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Select Contacts'**
|
/// **'Select Contacts'**
|
||||||
String get missingRecoveryContactsCardAction;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
|
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature3 => '50GB Memories Speicher';
|
String get familyFeature3 => '✓ 50GB Memories Speicher';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature4 => '✓ Flammen wiederherstellen';
|
String get familyFeature4 => '✓ Flammen wiederherstellen';
|
||||||
|
|
@ -758,6 +758,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$_temp0';
|
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
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -2348,4 +2383,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get missingRecoveryContactsCardAction => 'Kontakte auswählen';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -753,6 +753,41 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$_temp0';
|
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
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -2327,4 +2362,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get missingRecoveryContactsCardAction => 'Select Contacts';
|
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
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:cryptography_plus/cryptography_plus.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -58,6 +59,8 @@ class ProgressMultipartRequest extends http.MultipartRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoriesCloudService {
|
class MemoriesCloudService {
|
||||||
|
static final Map<String, Mutex> _fileMutexes = {};
|
||||||
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
|
@ -152,24 +155,38 @@ class MemoriesCloudService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> downloadThumbnail(MediaFileService media) async {
|
static Future<bool> downloadFromCloud(
|
||||||
final urls = await apiService.getMemoriesUrl(media.mediaFile.mediaId, true);
|
MediaFileService media, {
|
||||||
if (urls == null || !urls.hasFullDownloadUrl()) return false;
|
required bool isThumbnail,
|
||||||
|
}) async {
|
||||||
|
final mediaId = media.mediaFile.mediaId;
|
||||||
|
final mutex = _fileMutexes.putIfAbsent(mediaId, Mutex.new);
|
||||||
|
|
||||||
try {
|
return mutex.protect(() async {
|
||||||
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
|
final targetPath = isThumbnail ? media.thumbnailPath : media.storedPath;
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (targetPath.existsSync() && targetPath.lengthSync() > 0) {
|
||||||
return await _decryptFile(response.bodyBytes, media.thumbnailPath);
|
return true;
|
||||||
} else {
|
|
||||||
Log.warn(
|
|
||||||
'Failed to download thumbnai statuscode ${response.statusCode}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
Log.warn(e);
|
final urls = await apiService.getMemoriesUrl(mediaId, isThumbnail);
|
||||||
}
|
if (urls == null || !urls.hasFullDownloadUrl()) return false;
|
||||||
return false;
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return await _decryptFile(response.bodyBytes, targetPath);
|
||||||
|
} else {
|
||||||
|
Log.warn(
|
||||||
|
'Failed to download ${isThumbnail ? 'thumbnail' : 'full media'} statuscode ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.warn(e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _backupMemory(
|
Future<bool> _backupMemory(
|
||||||
|
|
|
||||||
117
lib/src/visual/components/delete_memories_dialog.comp.dart
Normal file
117
lib/src/visual/components/delete_memories_dialog.comp.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
|
||||||
class SelectableThumbnailComp extends StatelessWidget {
|
class SelectableThumbnailComp extends StatelessWidget {
|
||||||
const SelectableThumbnailComp({
|
const SelectableThumbnailComp({
|
||||||
|
|
@ -19,7 +19,7 @@ class SelectableThumbnailComp extends StatelessWidget {
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? context.color.primary : Colors.transparent,
|
color: isSelected ? primaryColor : Colors.transparent,
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
|
|
@ -49,9 +49,7 @@ class SelectableThumbnailComp extends StatelessWidget {
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected ? primaryColor : Colors.black38,
|
||||||
? context.color.primary
|
|
||||||
: Colors.black38,
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).brightness == Brightness.dark
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ enum MyButtonVariant {
|
||||||
secondaryDense,
|
secondaryDense,
|
||||||
secondaryMiddle,
|
secondaryMiddle,
|
||||||
error,
|
error,
|
||||||
|
errorMiddle,
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyButton extends StatefulWidget {
|
class MyButton extends StatefulWidget {
|
||||||
|
|
@ -33,7 +34,6 @@ class MyButton extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyButtonState extends State<MyButton> {
|
class _MyButtonState extends State<MyButton> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
|
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
|
||||||
|
|
@ -189,6 +189,25 @@ class _MyButtonState extends State<MyButton> {
|
||||||
fontWeight: FontWeight.bold,
|
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
|
final childButton = widget.variant == MyButtonVariant.text
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -91,13 +92,23 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
|
|
||||||
if (!hasThumbnail) {
|
if (!hasThumbnail) {
|
||||||
if (hasStored) {
|
if (hasStored) {
|
||||||
media.createThumbnail();
|
unawaited(
|
||||||
|
media.createThumbnail().then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_resolveImage();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
MemoriesCloudService.downloadThumbnail(media).then((success) {
|
unawaited(
|
||||||
if (mounted && success) {
|
MemoriesCloudService.downloadFromCloud(media, isThumbnail: true).then(
|
||||||
_resolveImage();
|
(success) {
|
||||||
}
|
if (mounted && success) {
|
||||||
});
|
_resolveImage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +128,8 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
|
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
|
||||||
widget.galleryItem.mediaService.mediaFile.mediaId) {
|
widget.galleryItem.mediaService.mediaFile.mediaId) {
|
||||||
_imageStream?.removeListener(_listener);
|
_imageStream?.removeListener(_listener);
|
||||||
|
_imageStream = null;
|
||||||
|
_imageProvider = null;
|
||||||
_imageInfo = null;
|
_imageInfo = null;
|
||||||
_retries = 0;
|
_retries = 0;
|
||||||
_selectedImageFile = null;
|
_selectedImageFile = null;
|
||||||
|
|
@ -235,32 +248,45 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (media.mediaFile.cloudState == CloudState.pending)
|
Builder(
|
||||||
const Positioned(
|
builder: (context) {
|
||||||
top: 6,
|
final hasStored =
|
||||||
right: 6,
|
media.storedPath.existsSync() &&
|
||||||
child: Icon(
|
media.storedPath.lengthSync() > 0;
|
||||||
Icons.cloud_upload_outlined,
|
final IconData iconData;
|
||||||
color: Colors.white70,
|
final Color color;
|
||||||
size: 16,
|
|
||||||
shadows: [
|
switch (media.mediaFile.cloudState) {
|
||||||
Shadow(color: Colors.black54, blurRadius: 4),
|
case CloudState.none:
|
||||||
],
|
iconData = Icons.cloud_off_outlined;
|
||||||
),
|
color = Colors.white54;
|
||||||
)
|
case CloudState.pending:
|
||||||
else if (media.mediaFile.cloudState == CloudState.uploaded)
|
iconData = Icons.cloud_upload_outlined;
|
||||||
const Positioned(
|
color = Colors.white70;
|
||||||
top: 6,
|
case CloudState.uploaded:
|
||||||
right: 6,
|
if (hasStored) {
|
||||||
child: Icon(
|
iconData = Icons.cloud_done_outlined;
|
||||||
Icons.cloud_done_outlined,
|
color = Colors.white;
|
||||||
color: Colors.white,
|
} else {
|
||||||
size: 16,
|
iconData = Icons.cloud_outlined;
|
||||||
shadows: [
|
color = Colors.white;
|
||||||
Shadow(color: Colors.black54, blurRadius: 4),
|
}
|
||||||
],
|
}
|
||||||
),
|
|
||||||
),
|
return Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Icon(
|
||||||
|
iconData,
|
||||||
|
color: color,
|
||||||
|
size: 16,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/themes/light.dart';
|
|
||||||
|
|
||||||
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||||
const SynchronizedViewerActionsToolbarComp({
|
const SynchronizedViewerActionsToolbarComp({
|
||||||
|
|
@ -27,94 +26,125 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Positioned(
|
return Padding(
|
||||||
bottom: MediaQuery.paddingOf(context).bottom + 24,
|
padding: const EdgeInsets.all(12),
|
||||||
left: 0,
|
child: Container(
|
||||||
right: 0,
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
color: Colors.black.withValues(alpha: 0.50),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
borderRadius: BorderRadius.circular(20),
|
||||||
children: [
|
),
|
||||||
if (showStoreButton) ...[
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
IconButton(
|
child: Row(
|
||||||
icon: isImageSaving
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
? const SizedBox(
|
children: [
|
||||||
width: 16,
|
if (showStoreButton)
|
||||||
height: 16,
|
_ToolbarAction(
|
||||||
child: CircularProgressIndicator.adaptive(
|
icon: isImageSaving
|
||||||
strokeWidth: 2,
|
? const SizedBox(
|
||||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const FaIcon(
|
||||||
|
FontAwesomeIcons.floppyDisk,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
)
|
label: context.lang.galleryActionSave,
|
||||||
: const FaIcon(
|
onTap: isImageSaving ? null : onStore,
|
||||||
FontAwesomeIcons.floppyDisk,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: isImageSaving ? null : onStore,
|
|
||||||
tooltip: 'Store media',
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.download,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 19,
|
||||||
|
),
|
||||||
|
label: context.lang.galleryActionExport,
|
||||||
|
onTap: onExport,
|
||||||
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: Icon(
|
||||||
|
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
|
||||||
],
|
],
|
||||||
IconButton(
|
),
|
||||||
icon: const FaIcon(
|
),
|
||||||
FontAwesomeIcons.fileArrowDown,
|
);
|
||||||
color: Colors.white,
|
}
|
||||||
size: 21,
|
}
|
||||||
),
|
|
||||||
onPressed: onExport,
|
class _ToolbarAction extends StatelessWidget {
|
||||||
tooltip: context.lang.galleryExport,
|
const _ToolbarAction({
|
||||||
style: IconButton.styleFrom(
|
required this.icon,
|
||||||
backgroundColor: Colors.black54,
|
required this.label,
|
||||||
padding: const EdgeInsets.all(12),
|
required this.onTap,
|
||||||
),
|
});
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
final Widget icon;
|
||||||
IconButton(
|
final String label;
|
||||||
icon: Icon(
|
final VoidCallback? onTap;
|
||||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
|
||||||
color: isFavorite ? Colors.redAccent : Colors.white,
|
@override
|
||||||
size: 24,
|
Widget build(BuildContext context) {
|
||||||
),
|
return Expanded(
|
||||||
onPressed: onToggleFavorite,
|
child: Opacity(
|
||||||
tooltip: 'Favorite',
|
opacity: onTap == null ? 0.4 : 1.0,
|
||||||
style: IconButton.styleFrom(
|
child: InkWell(
|
||||||
backgroundColor: Colors.black54,
|
onTap: onTap,
|
||||||
padding: const EdgeInsets.all(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
const SizedBox(width: 16),
|
child: Column(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(
|
children: [
|
||||||
Icons.delete_outline,
|
SizedBox(
|
||||||
color: Colors.white,
|
height: 24,
|
||||||
size: 24,
|
child: Center(child: icon),
|
||||||
),
|
),
|
||||||
onPressed: onDelete,
|
const SizedBox(height: 6),
|
||||||
tooltip: context.lang.galleryDelete,
|
Text(
|
||||||
style: IconButton.styleFrom(
|
label,
|
||||||
backgroundColor: Colors.black54,
|
maxLines: 1,
|
||||||
padding: const EdgeInsets.all(12),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
const SizedBox(width: 16),
|
fontSize: 10,
|
||||||
IconButton(
|
fontWeight: FontWeight.w600,
|
||||||
icon: const FaIcon(
|
letterSpacing: 0.1,
|
||||||
FontAwesomeIcons.solidPaperPlane,
|
),
|
||||||
color: primaryColor,
|
),
|
||||||
size: 22,
|
],
|
||||||
),
|
),
|
||||||
onPressed: onShare,
|
),
|
||||||
tooltip: context.lang.shareImagedEditorSendImage,
|
),
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/services/memories/memories.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.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/draggable_scrollbar.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.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/flashback_banner.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.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';
|
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
||||||
|
|
||||||
class MemoriesView extends StatefulWidget {
|
class MemoriesView extends StatefulWidget {
|
||||||
|
|
@ -270,57 +271,41 @@ class MemoriesViewState extends State<MemoriesView>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchDelete() async {
|
Future<void> _batchDelete({bool? forceDeleteCompletely}) async {
|
||||||
final count = _selectedMediaIds.length;
|
final count = _selectedMediaIds.length;
|
||||||
final items = _service.currentState.galleryItems;
|
final items = _service.currentState.galleryItems;
|
||||||
final selectedList = _selectedMediaIds.toList();
|
final selectedList = _selectedMediaIds.toList();
|
||||||
|
|
||||||
var hasCloudBackup = false;
|
var deleteCompletely = forceDeleteCompletely;
|
||||||
for (final id in selectedList) {
|
if (deleteCompletely == null) {
|
||||||
final item = items
|
var hasCloudBackup = false;
|
||||||
.where((e) => e.mediaService.mediaFile.mediaId == id)
|
for (final id in selectedList) {
|
||||||
.firstOrNull;
|
final item = items
|
||||||
if (item != null &&
|
.where((e) => e.mediaService.mediaFile.mediaId == id)
|
||||||
item.mediaService.mediaFile.cloudState != CloudState.none) {
|
.firstOrNull;
|
||||||
hasCloudBackup = true;
|
if (item != null &&
|
||||||
break;
|
item.mediaService.mediaFile.cloudState != CloudState.none) {
|
||||||
|
hasCloudBackup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
bool? deleteCompletely;
|
deleteCompletely = await showDeleteMemoriesDialog(
|
||||||
if (hasCloudBackup) {
|
|
||||||
deleteCompletely = await showDialog<bool>(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
count: count,
|
||||||
title: Text(context.lang.deleteImageTitle),
|
hasCloudBackup: hasCloudBackup,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else if (deleteCompletely) {
|
||||||
final confirmed = await showAlertDialog(
|
final confirmed = await showAlertDialog(
|
||||||
context,
|
context,
|
||||||
context.lang.deleteImageTitle,
|
context.lang.deleteImageTitle,
|
||||||
context.lang.deleteMemoriesBody(count),
|
context.lang.deleteMemoriesBody(count),
|
||||||
);
|
);
|
||||||
if (confirmed) deleteCompletely = true;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteCompletely == null) return;
|
if (deleteCompletely == null) return;
|
||||||
|
final isCompletely = deleteCompletely;
|
||||||
|
|
||||||
await _showProgressDialog(
|
await _showProgressDialog(
|
||||||
'Deleting memories...',
|
'Deleting memories...',
|
||||||
|
|
@ -331,12 +316,14 @@ class MemoriesViewState extends State<MemoriesView>
|
||||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
if (deleteCompletely!) {
|
if (isCompletely) {
|
||||||
item.mediaService.fullMediaRemoval();
|
item.mediaService.fullMediaRemoval();
|
||||||
await apiService.deleteMemory(mediaId);
|
await apiService.deleteMemory(mediaId);
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
||||||
} else {
|
} else {
|
||||||
item.mediaService.storedPath.deleteSync();
|
if (item.mediaService.storedPath.existsSync()) {
|
||||||
|
item.mediaService.storedPath.deleteSync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setProgress((i + 1) / selectedList.length);
|
setProgress((i + 1) / selectedList.length);
|
||||||
|
|
@ -515,9 +502,9 @@ class MemoriesViewState extends State<MemoriesView>
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Cloud backup limit reached! Please upgrade your plan or free up space.',
|
context.lang.memoriesBackupLimitReached,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -565,65 +552,128 @@ class MemoriesViewState extends State<MemoriesView>
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
title: const Text(
|
leading: _selectionMode
|
||||||
'Memories',
|
? IconButton(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
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,
|
floating: true,
|
||||||
snap: true,
|
snap: true,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: context.color.surface,
|
backgroundColor: context.color.surface,
|
||||||
actions: [
|
actions: _selectionMode
|
||||||
if (state.isLoading)
|
? [
|
||||||
Padding(
|
IconButton(
|
||||||
padding: const EdgeInsets.symmetric(
|
icon: const Icon(
|
||||||
horizontal: 16,
|
Icons.delete_outline,
|
||||||
),
|
),
|
||||||
child: Center(
|
onPressed: _batchDelete,
|
||||||
child: Tooltip(
|
),
|
||||||
message: context.lang
|
Builder(
|
||||||
.migrationOfMemories(
|
builder: (context) {
|
||||||
state.filesToMigrate,
|
final visibleCount =
|
||||||
),
|
_filterFavoritesOnly
|
||||||
child: SizedBox(
|
? state.galleryItems
|
||||||
width: 20,
|
.where(
|
||||||
height: 20,
|
(e) => e
|
||||||
child: CircularProgressIndicator(
|
.mediaService
|
||||||
value: state.migrationProgress,
|
.mediaFile
|
||||||
strokeWidth: 2.5,
|
.isFavorite,
|
||||||
valueColor:
|
)
|
||||||
AlwaysStoppedAnimation(
|
.length
|
||||||
context.color.primary,
|
: state.galleryItems.length;
|
||||||
|
final areAllSelected =
|
||||||
|
visibleCount > 0 &&
|
||||||
|
_selectedMediaIds.length >=
|
||||||
|
visibleCount;
|
||||||
|
|
||||||
|
return MemoriesSelectionMenuComp(
|
||||||
|
areAllSelected: areAllSelected,
|
||||||
|
onSelectAll: _selectAll,
|
||||||
|
onExport: _batchExport,
|
||||||
|
onFavorite: _batchFavorite,
|
||||||
|
onDeleteCompletely: () =>
|
||||||
|
_batchDelete(
|
||||||
|
forceDeleteCompletely: true,
|
||||||
),
|
),
|
||||||
backgroundColor: context
|
onDeleteLocally: () =>
|
||||||
.color
|
_batchDelete(
|
||||||
.primary
|
forceDeleteCompletely:
|
||||||
.withValues(alpha: 0.2),
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
if (state.isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Tooltip(
|
||||||
|
message: context.lang
|
||||||
|
.migrationOfMemories(
|
||||||
|
state.filesToMigrate,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value:
|
||||||
|
state.migrationProgress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
valueColor:
|
||||||
|
AlwaysStoppedAnimation(
|
||||||
|
context.color.primary,
|
||||||
|
),
|
||||||
|
backgroundColor: context
|
||||||
|
.color
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_filterFavoritesOnly
|
||||||
|
? Icons.favorite
|
||||||
|
: Icons.favorite_border,
|
||||||
|
color: _filterFavoritesOnly
|
||||||
|
? Colors.redAccent
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_filterFavoritesOnly =
|
||||||
|
!_filterFavoritesOnly;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_filterFavoritesOnly
|
|
||||||
? Icons.favorite
|
|
||||||
: Icons.favorite_border,
|
|
||||||
color: _filterFavoritesOnly
|
|
||||||
? Colors.redAccent
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_filterFavoritesOnly =
|
|
||||||
!_filterFavoritesOnly;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
tooltip: _filterFavoritesOnly
|
|
||||||
? 'Show all'
|
|
||||||
: 'Show favorites only',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
MemoriesFlashbackBannerComp(
|
MemoriesFlashbackBannerComp(
|
||||||
lastYears: lastYears,
|
lastYears: lastYears,
|
||||||
|
|
@ -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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import 'dart:math';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.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/services/api/mediafiles/upload.api.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.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/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/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_actions_toolbar.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_item.comp.dart';
|
||||||
|
|
||||||
class SynchronizedImageViewerScreen extends StatefulWidget {
|
class SynchronizedImageViewerScreen extends StatefulWidget {
|
||||||
const SynchronizedImageViewerScreen({
|
const SynchronizedImageViewerScreen({
|
||||||
|
|
@ -68,7 +66,7 @@ class _SynchronizedImageViewerScreenState
|
||||||
if (item.mediaService.mediaFile.isFavorite) {
|
if (item.mediaService.mediaFile.isFavorite) {
|
||||||
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||||
}
|
}
|
||||||
if (item.mediaService.storedPath.existsSync()) {
|
if (item.mediaService.mediaFile.stored) {
|
||||||
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,36 +155,46 @@ class _SynchronizedImageViewerScreenState
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteFile() async {
|
Future<void> _deleteFile() async {
|
||||||
final confirmed = await showAlertDialog(
|
final item = widget.galleryItems[_currentIndex];
|
||||||
context,
|
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||||
context.lang.deleteImageTitle,
|
final hasCloudBackup =
|
||||||
context.lang.deleteImageBody,
|
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();
|
if (deleteCompletely) {
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(
|
item.mediaService.fullMediaRemoval();
|
||||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId,
|
await apiService.deleteMemory(mediaId);
|
||||||
);
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
||||||
|
|
||||||
widget.galleryItems.removeAt(_currentIndex);
|
widget.galleryItems.removeAt(_currentIndex);
|
||||||
|
|
||||||
if (widget.galleryItems.isEmpty) {
|
if (widget.galleryItems.isEmpty) {
|
||||||
if (mounted) Navigator.pop(context, true);
|
if (mounted) Navigator.pop(context, true);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentIndex >= widget.galleryItems.length) {
|
||||||
|
_currentIndex = widget.galleryItems.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newId =
|
||||||
|
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
||||||
|
_currentlyViewedMediaIdNotifier.value = newId;
|
||||||
|
widget.activeMediaIdNotifier.value = newId;
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
if (item.mediaService.storedPath.existsSync()) {
|
||||||
|
item.mediaService.storedPath.deleteSync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_currentIndex >= widget.galleryItems.length) {
|
|
||||||
_currentIndex = widget.galleryItems.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
final newId =
|
|
||||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
|
||||||
_currentlyViewedMediaIdNotifier.value = newId;
|
|
||||||
widget.activeMediaIdNotifier.value = newId;
|
|
||||||
|
|
||||||
setState(() {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _exportFile() async {
|
Future<void> _exportFile() async {
|
||||||
|
|
@ -308,99 +316,50 @@ class _SynchronizedImageViewerScreenState
|
||||||
widget.activeMediaIdNotifier.value = newMediaId;
|
widget.activeMediaIdNotifier.value = newMediaId;
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = widget.galleryItems[index];
|
return SynchronizedViewerItemComp(
|
||||||
final itemMediaId = item.mediaService.mediaFile.mediaId;
|
item: widget.galleryItems[index],
|
||||||
|
currentlyViewedMediaIdNotifier:
|
||||||
var filePath = item.mediaService.storedPath;
|
_currentlyViewedMediaIdNotifier,
|
||||||
if (!filePath.existsSync()) {
|
onZoomChanged: (zoomed) {
|
||||||
filePath = item.mediaService.tempPath;
|
if (_isZoomed != zoomed) {
|
||||||
if (!filePath.existsSync()) {
|
setState(() {
|
||||||
filePath = item.mediaService.thumbnailPath;
|
_isZoomed = zoomed;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
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;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
SynchronizedViewerActionsToolbarComp(
|
Positioned(
|
||||||
isFavorite: _favoritedMediaIds.contains(currentMediaId),
|
bottom: MediaQuery.paddingOf(context).bottom + 16,
|
||||||
onShare: _shareMediaFile,
|
left: 0,
|
||||||
onExport: _exportFile,
|
right: 0,
|
||||||
onToggleFavorite: () => _toggleFavorite(currentMediaId),
|
child: AnimatedOpacity(
|
||||||
onDelete: _deleteFile,
|
opacity: _isZoomed ? 0.0 : 1.0,
|
||||||
showStoreButton: !_storedMediaIds.contains(currentMediaId),
|
duration: const Duration(milliseconds: 200),
|
||||||
onStore: _storeMediaFile,
|
curve: Curves.easeInOut,
|
||||||
isImageSaving: _isSaving,
|
child: IgnorePointer(
|
||||||
|
ignoring: _isZoomed,
|
||||||
|
child: Center(
|
||||||
|
child: SynchronizedViewerActionsToolbarComp(
|
||||||
|
isFavorite: _favoritedMediaIds.contains(
|
||||||
|
currentMediaId,
|
||||||
|
),
|
||||||
|
onShare: _shareMediaFile,
|
||||||
|
onExport: _exportFile,
|
||||||
|
onToggleFavorite: () =>
|
||||||
|
_toggleFavorite(currentMediaId),
|
||||||
|
onDelete: _deleteFile,
|
||||||
|
showStoreButton: !_storedMediaIds.contains(
|
||||||
|
currentMediaId,
|
||||||
|
),
|
||||||
|
onStore: _storeMediaFile,
|
||||||
|
isImageSaving: _isSaving,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/model/json/backup.model.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/backup.service.dart';
|
||||||
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.element.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';
|
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> {
|
class _BackupViewState extends State<BackupView> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
CurrentBackupStatus? _backupStatus;
|
CurrentBackupStatus? _backupStatus;
|
||||||
server.Response_MemoriesUsage? _memoriesUsage;
|
|
||||||
StreamSubscription<void>? _backupUpdateSub;
|
StreamSubscription<void>? _backupUpdateSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -45,11 +41,9 @@ class _BackupViewState extends State<BackupView> {
|
||||||
Future<void> _loadBackupStatus() async {
|
Future<void> _loadBackupStatus() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final status = await BackupService.getData();
|
final status = await BackupService.getData();
|
||||||
final memoriesUsage = await apiService.getMemoriesUsage();
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_backupStatus = status;
|
_backupStatus = status;
|
||||||
_memoriesUsage = memoriesUsage;
|
|
||||||
_isLoading = false;
|
_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,
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
context.navPush(const PasswordLessRecoverySetup()),
|
context.navPush(const PasswordLessRecoverySetup()),
|
||||||
child: const Text('Setup Passwordless Recovery'),
|
child: Text(context.lang.passwordlessRecoveryEnableBtn),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
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/locator.dart';
|
||||||
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.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';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
class ManageStorageView extends StatefulWidget {
|
class ManageStorageView extends StatefulWidget {
|
||||||
|
|
@ -12,6 +20,7 @@ class ManageStorageView extends StatefulWidget {
|
||||||
|
|
||||||
class _ManageStorageViewState extends State<ManageStorageView> {
|
class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
Map<MediaType, int> _stats = {};
|
Map<MediaType, int> _stats = {};
|
||||||
|
server.Response_MemoriesUsage? _memoriesUsage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -21,15 +30,20 @@ class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
|
|
||||||
Future<void> _loadStats() async {
|
Future<void> _loadStats() async {
|
||||||
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
|
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
|
||||||
|
final memoriesUsage = await apiService.getMemoriesUsage();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_stats = stats;
|
_stats = stats;
|
||||||
|
_memoriesUsage = memoriesUsage;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final currentPlan = context.watch<PurchasesProvider>().plan;
|
||||||
|
final isFreePlan = currentPlan == SubscriptionPlan.Free;
|
||||||
|
|
||||||
final totalBytes = _stats.entries
|
final totalBytes = _stats.entries
|
||||||
.where((e) => e.key != MediaType.audio)
|
.where((e) => e.key != MediaType.audio)
|
||||||
.fold<int>(0, (a, b) => a + b.value);
|
.fold<int>(0, (a, b) => a + b.value);
|
||||||
|
|
@ -44,8 +58,148 @@ class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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.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(
|
Text(
|
||||||
context.lang.settingsStorageUsed,
|
isFreePlan
|
||||||
|
? context.lang.settingsStorageUsed
|
||||||
|
: context.lang.settingsStorageLocal,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue