Merge remote-tracking branch 'origin/master' into theme

This commit is contained in:
Neeraj Gupta 2022-03-26 07:18:45 +05:30
commit 2c03a09808
17 changed files with 331 additions and 60 deletions

View file

@ -75,7 +75,7 @@ class Configuration {
String _volatilePassword; String _volatilePassword;
final _secureStorageOptionsIOS = final _secureStorageOptionsIOS =
IOSOptions(accessibility: IOSAccessibility.first_unlock_this_device); IOSOptions(accessibility: IOSAccessibility.first_unlock);
Future<void> init() async { Future<void> init() async {
_preferences = await SharedPreferences.getInstance(); _preferences = await SharedPreferences.getInstance();

View file

@ -24,3 +24,6 @@ const kThumbnailServerLoadDeferDuration = Duration(milliseconds: 80);
// 256 bit key maps to 24 words // 256 bit key maps to 24 words
// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#Generating_the_mnemonic // https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#Generating_the_mnemonic
const kMnemonicKeyWordCount = 24; const kMnemonicKeyWordCount = 24;
// https://stackoverflow.com/a/61162219
const kDragSensitivity = 8;

View file

@ -27,6 +27,9 @@ class CollectionsDB {
static final columnVersion = 'version'; static final columnVersion = 'version';
static final columnSharees = 'sharees'; static final columnSharees = 'sharees';
static final columnPublicURLs = 'public_urls'; static final columnPublicURLs = 'public_urls';
// MMD -> Magic Metadata
static final columnMMdEncodedJson = 'mmd_encoded_json';
static final columnMMdVersion = 'mmd_ver';
static final columnUpdationTime = 'updation_time'; static final columnUpdationTime = 'updation_time';
static final columnIsDeleted = 'is_deleted'; static final columnIsDeleted = 'is_deleted';
@ -37,6 +40,7 @@ class CollectionsDB {
...addVersion(), ...addVersion(),
...addIsDeleted(), ...addIsDeleted(),
...addPublicURLs(), ...addPublicURLs(),
...addPrivateMetadata(),
]; ];
final dbConfig = MigrationConfig( final dbConfig = MigrationConfig(
@ -138,6 +142,17 @@ class CollectionsDB {
]; ];
} }
static List<String> addPrivateMetadata() {
return [
'''
ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}';
''',
'''
ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0;
'''
];
}
Future<List<dynamic>> insert(List<Collection> collections) async { Future<List<dynamic>> insert(List<Collection> collections) async {
final db = await instance.database; final db = await instance.database;
var batch = db.batch(); var batch = db.batch();
@ -204,11 +219,13 @@ class CollectionsDB {
} else { } else {
row[columnIsDeleted] = _sqlBoolFalse; row[columnIsDeleted] = _sqlBoolFalse;
} }
row[columnMMdVersion] = collection.mMdVersion ?? 0;
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
return row; return row;
} }
Collection _convertToCollection(Map<String, dynamic> row) { Collection _convertToCollection(Map<String, dynamic> row) {
return Collection( Collection result = Collection(
row[columnID], row[columnID],
User.fromJson(row[columnOwner]), User.fromJson(row[columnOwner]),
row[columnEncryptedKey], row[columnEncryptedKey],
@ -232,5 +249,8 @@ class CollectionsDB {
// default to False is columnIsDeleted is not set // default to False is columnIsDeleted is not set
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue, isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
); );
result.mMdVersion = row[columnMMdVersion] ?? 0;
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
return result;
} }
} }

View file

@ -399,7 +399,10 @@ class FilesDB {
Future<FileLoadResult> getAllUploadedFiles( Future<FileLoadResult> getAllUploadedFiles(
int startTime, int endTime, int ownerID, int startTime, int endTime, int ownerID,
{int limit, bool asc, int visibility = kVisibilityVisible}) async { {int limit,
bool asc,
int visibility = kVisibilityVisible,
Set<int> ignoredCollectionIDs}) async {
final db = await instance.database; final db = await instance.database;
final order = (asc ?? false ? 'ASC' : 'DESC'); final order = (asc ?? false ? 'ASC' : 'DESC');
final results = await db.query( final results = await db.query(
@ -413,13 +416,14 @@ class FilesDB {
limit: limit, limit: limit,
); );
final files = _convertToFiles(results); final files = _convertToFiles(results);
List<File> deduplicatedFiles = _deduplicatedFiles(files); List<File> deduplicatedFiles =
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
return FileLoadResult(deduplicatedFiles, files.length == limit); return FileLoadResult(deduplicatedFiles, files.length == limit);
} }
Future<FileLoadResult> getAllLocalAndUploadedFiles( Future<FileLoadResult> getAllLocalAndUploadedFiles(
int startTime, int endTime, int ownerID, int startTime, int endTime, int ownerID,
{int limit, bool asc}) async { {int limit, bool asc, Set<int> ignoredCollectionIDs}) async {
final db = await instance.database; final db = await instance.database;
final order = (asc ?? false ? 'ASC' : 'DESC'); final order = (asc ?? false ? 'ASC' : 'DESC');
final results = await db.query( final results = await db.query(
@ -433,13 +437,14 @@ class FilesDB {
limit: limit, limit: limit,
); );
final files = _convertToFiles(results); final files = _convertToFiles(results);
List<File> deduplicatedFiles = _deduplicatedFiles(files); List<File> deduplicatedFiles =
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
return FileLoadResult(deduplicatedFiles, files.length == limit); return FileLoadResult(deduplicatedFiles, files.length == limit);
} }
Future<FileLoadResult> getImportantFiles( Future<FileLoadResult> getImportantFiles(
int startTime, int endTime, int ownerID, List<String> paths, int startTime, int endTime, int ownerID, List<String> paths,
{int limit, bool asc}) async { {int limit, bool asc, Set<int> ignoredCollectionIDs}) async {
final db = await instance.database; final db = await instance.database;
String inParam = ""; String inParam = "";
for (final path in paths) { for (final path in paths) {
@ -458,15 +463,21 @@ class FilesDB {
limit: limit, limit: limit,
); );
final files = _convertToFiles(results); final files = _convertToFiles(results);
List<File> deduplicatedFiles = _deduplicatedFiles(files); List<File> deduplicatedFiles =
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
return FileLoadResult(deduplicatedFiles, files.length == limit); return FileLoadResult(deduplicatedFiles, files.length == limit);
} }
List<File> _deduplicatedFiles(List<File> files) { List<File> _deduplicatedAndFilterIgnoredFiles(
List<File> files, Set<int> ignoredCollectionIDs) {
final uploadedFileIDs = <int>{}; final uploadedFileIDs = <int>{};
final List<File> deduplicatedFiles = []; final List<File> deduplicatedFiles = [];
for (final file in files) { for (final file in files) {
final id = file.uploadedFileID; final id = file.uploadedFileID;
if (ignoredCollectionIDs != null &&
ignoredCollectionIDs.contains(file.collectionID)) {
continue;
}
if (id != null && id != -1 && uploadedFileIDs.contains(id)) { if (id != null && id != -1 && uploadedFileIDs.contains(id)) {
continue; continue;
} }

View file

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:core';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:photos/models/magic_metadata.dart';
class Collection { class Collection {
final int id; final int id;
@ -16,6 +18,14 @@ class Collection {
final List<PublicURL> publicURLs; final List<PublicURL> publicURLs;
final int updationTime; final int updationTime;
final bool isDeleted; final bool isDeleted;
String mMdEncodedJson;
int mMdVersion = 0;
CollectionMagicMetadata _mmd;
CollectionMagicMetadata get magicMetadata =>
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
set magicMetadata(val) => _mmd = val;
Collection( Collection(
this.id, this.id,
@ -33,6 +43,10 @@ class Collection {
this.isDeleted = false, this.isDeleted = false,
}); });
bool isArchived() {
return mMdVersion > 0 && magicMetadata.visibility == kVisibilityArchive;
}
static CollectionType typeFromString(String type) { static CollectionType typeFromString(String type) {
switch (type) { switch (type) {
case "folder": case "folder":
@ -54,8 +68,8 @@ class Collection {
} }
} }
Collection copyWith({ Collection copyWith(
int id, {int id,
User owner, User owner,
String encryptedKey, String encryptedKey,
String keyDecryptionNonce, String keyDecryptionNonce,
@ -68,8 +82,9 @@ class Collection {
List<PublicURL> publicURLs, List<PublicURL> publicURLs,
int updationTime, int updationTime,
bool isDeleted, bool isDeleted,
}) { String mMdEncodedJson,
return Collection( int mMdVersion}) {
Collection result = Collection(
id ?? this.id, id ?? this.id,
owner ?? this.owner, owner ?? this.owner,
encryptedKey ?? this.encryptedKey, encryptedKey ?? this.encryptedKey,
@ -84,6 +99,9 @@ class Collection {
updationTime ?? this.updationTime, updationTime ?? this.updationTime,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,
); );
result.mMdVersion = mMdVersion ?? this.mMdVersion;
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
return result;
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {

View file

@ -62,3 +62,30 @@ class PubMagicMetadata {
); );
} }
} }
class CollectionMagicMetadata {
// 0 -> visible
// 1 -> archived
// 2 -> hidden etc?
int visibility;
CollectionMagicMetadata({this.visibility});
factory CollectionMagicMetadata.fromEncodedJson(String encodedJson) =>
CollectionMagicMetadata.fromJson(jsonDecode(encodedJson));
factory CollectionMagicMetadata.fromJson(dynamic json) => CollectionMagicMetadata.fromMap(json);
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map[kMagicKeyVisibility] = visibility;
return map;
}
factory CollectionMagicMetadata.fromMap(Map<String, dynamic> map) {
if (map == null) return null;
return CollectionMagicMetadata(
visibility: map[kMagicKeyVisibility] ?? kVisibilityVisible,
);
}
}

View file

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -19,7 +20,9 @@ import 'package:photos/events/local_photos_updated_event.dart';
import 'package:photos/models/collection.dart'; import 'package:photos/models/collection.dart';
import 'package:photos/models/collection_file_item.dart'; import 'package:photos/models/collection_file_item.dart';
import 'package:photos/models/file.dart'; import 'package:photos/models/file.dart';
import 'package:photos/models/magic_metadata.dart';
import 'package:photos/services/app_lifecycle_service.dart'; import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/file_magic_service.dart';
import 'package:photos/services/remote_sync_service.dart'; import 'package:photos/services/remote_sync_service.dart';
import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/file_download_util.dart'; import 'package:photos/utils/file_download_util.dart';
@ -27,7 +30,7 @@ import 'package:shared_preferences/shared_preferences.dart';
class CollectionsService { class CollectionsService {
static final _collectionSyncTimeKeyPrefix = "collection_sync_time_"; static final _collectionSyncTimeKeyPrefix = "collection_sync_time_";
static final _collectionsSyncTimeKey = "collections_sync_time"; static final _collectionsSyncTimeKey = "collections_sync_time_x";
static const int kMaximumWriteAttempts = 5; static const int kMaximumWriteAttempts = 5;
@ -55,6 +58,7 @@ class CollectionsService {
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
final collections = await _db.getAllCollections(); final collections = await _db.getAllCollections();
for (final collection in collections) { for (final collection in collections) {
_cacheCollectionAttributes(collection); _cacheCollectionAttributes(collection);
} }
@ -74,8 +78,7 @@ class CollectionsService {
_prefs.getInt(_collectionsSyncTimeKey) ?? 0; _prefs.getInt(_collectionsSyncTimeKey) ?? 0;
// Might not have synced the collection fully // Might not have synced the collection fully
final fetchedCollections = final fetchedCollections = await _fetchCollections(0);
await _fetchCollections(lastCollectionUpdationTime ?? 0);
final updatedCollections = <Collection>[]; final updatedCollections = <Collection>[];
int maxUpdationTime = lastCollectionUpdationTime; int maxUpdationTime = lastCollectionUpdationTime;
final ownerID = _config.getUserID(); final ownerID = _config.getUserID();
@ -127,6 +130,14 @@ class CollectionsService {
return updatedCollections; return updatedCollections;
} }
Set<int> getArchivedCollections() {
return _collectionIDToCollections.values
.toList()
.where((element) => element.isArchived())
.map((e) => e.id)
.toSet();
}
int getCollectionSyncTime(int collectionID) { int getCollectionSyncTime(int collectionID) {
return _prefs return _prefs
.getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()) ?? .getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()) ??
@ -273,6 +284,64 @@ class CollectionsService {
} }
} }
Future<void> updateMagicMetadata(
Collection collection, Map<String, dynamic> newMetadataUpdate) async {
final int ownerID = Configuration.instance.getUserID();
try {
if (collection.owner.id != ownerID) {
throw AssertionError("cannot modify albums not owned by you");
}
// read the existing magic metadata and apply new updates to existing data
// current update is simple replace. This will be enhanced in the future,
// as required.
Map<String, dynamic> jsonToUpdate =
jsonDecode(collection.mMdEncodedJson ?? '{}');
newMetadataUpdate.forEach((key, value) {
jsonToUpdate[key] = value;
});
// update the local information so that it's reflected on UI
collection.mMdEncodedJson = jsonEncode(jsonToUpdate);
collection.magicMetadata = CollectionMagicMetadata.fromJson(jsonToUpdate);
final key = getCollectionKey(collection.id);
final encryptedMMd = await CryptoUtil.encryptChaCha(
utf8.encode(jsonEncode(jsonToUpdate)), key);
// for required field, the json validator on golang doesn't treat 0 as valid
// value. Instead of changing version to ptr, decided to start version with 1.
int currentVersion = max(collection.mMdVersion, 1);
final params = UpdateMagicMetadataRequest(
id: collection.id,
magicMetadata: MetadataRequest(
version: currentVersion,
count: jsonToUpdate.length,
data: Sodium.bin2base64(encryptedMMd.encryptedData),
header: Sodium.bin2base64(encryptedMMd.header),
),
);
await _dio.put(
Configuration.instance.getHttpEndpoint() +
"/collections/magic-metadata",
data: params,
options: Options(
headers: {"X-Auth-Token": Configuration.instance.getToken()}),
);
collection.mMdVersion = currentVersion + 1;
_cacheCollectionAttributes(collection);
// trigger sync to fetch the latest collection state from server
sync();
} on DioError catch (e) {
if (e.response != null && e.response.statusCode == 409) {
_logger.severe('collection magic data out of sync');
sync();
}
rethrow;
} catch (e, s) {
_logger.severe("failed to sync magic metadata", e, s);
rethrow;
}
}
Future<void> createShareUrl(Collection collection) async { Future<void> createShareUrl(Collection collection) async {
try { try {
final response = await _dio.post( final response = await _dio.post(
@ -361,8 +430,20 @@ class CollectionsService {
final List<Collection> collections = []; final List<Collection> collections = [];
if (response != null) { if (response != null) {
final c = response.data["collections"]; final c = response.data["collections"];
for (final collection in c) { for (final collectionData in c) {
collections.add(Collection.fromMap(collection)); final collection = Collection.fromMap(collectionData);
if (collectionData['magicMetadata'] != null) {
final decryptionKey = _getDecryptedKey(collection);
final utfEncodedMmd = await CryptoUtil.decryptChaCha(
Sodium.base642bin(collectionData['magicMetadata']['data']),
decryptionKey,
Sodium.base642bin(collectionData['magicMetadata']['header']));
collection.mMdEncodedJson = utf8.decode(utfEncodedMmd);
collection.mMdVersion = collectionData['magicMetadata']['version'];
collection.magicMetadata = CollectionMagicMetadata.fromEncodedJson(
collection.mMdEncodedJson);
}
collections.add(collection);
} }
} }
return collections; return collections;
@ -408,7 +489,19 @@ class CollectionsService {
headers: {"X-Auth-Token": Configuration.instance.getToken()}), headers: {"X-Auth-Token": Configuration.instance.getToken()}),
); );
assert(response != null && response.data != null); assert(response != null && response.data != null);
final collection = Collection.fromMap(response.data["collection"]); final collectionData = response.data["collection"];
final collection = Collection.fromMap(collectionData);
if (collectionData['magicMetadata'] != null) {
final decryptionKey = _getDecryptedKey(collection);
final utfEncodedMmd = await CryptoUtil.decryptChaCha(
Sodium.base642bin(collectionData['magicMetadata']['data']),
decryptionKey,
Sodium.base642bin(collectionData['magicMetadata']['header']));
collection.mMdEncodedJson = utf8.decode(utfEncodedMmd);
collection.mMdVersion = collectionData['magicMetadata']['version'];
collection.magicMetadata =
CollectionMagicMetadata.fromEncodedJson(collection.mMdEncodedJson);
}
await _db.insert(List.from([collection])); await _db.insert(List.from([collection]));
_cacheCollectionAttributes(collection); _cacheCollectionAttributes(collection);
return collection; return collection;

View file

@ -217,7 +217,7 @@ class RemoteSyncService {
// uploaded yet. These files should ignore video backup & ignored files filter // uploaded yet. These files should ignore video backup & ignored files filter
filesToBeUploaded = await _db.getPendingManualUploads(); filesToBeUploaded = await _db.getPendingManualUploads();
} }
_moveVideosToEnd(filesToBeUploaded); _sortByTimeAndType(filesToBeUploaded);
_logger.info( _logger.info(
filesToBeUploaded.length.toString() + " new files to be uploaded."); filesToBeUploaded.length.toString() + " new files to be uploaded.");
return filesToBeUploaded; return filesToBeUploaded;
@ -453,10 +453,12 @@ class RemoteSyncService {
return Platform.isIOS && !AppLifecycleService.instance.isForeground; return Platform.isIOS && !AppLifecycleService.instance.isForeground;
} }
void _moveVideosToEnd(List<File> file) { // _sortByTimeAndType moves videos to end and sort by creation time (desc).
// This is done to upload most recent photo first.
void _sortByTimeAndType(List<File> file) {
file.sort((first, second) { file.sort((first, second) {
if (first.fileType == second.fileType) { if (first.fileType == second.fileType) {
return 0; return second.creationTime.compareTo(first.creationTime);
} else if (first.fileType == FileType.video) { } else if (first.fileType == FileType.video) {
return 1; return 1;
} else { } else {

View file

@ -487,7 +487,8 @@ class CollectionItem extends StatelessWidget {
tag: "collection" + c.thumbnail.tag(), tag: "collection" + c.thumbnail.tag(),
child: ThumbnailWidget( child: ThumbnailWidget(
c.thumbnail, c.thumbnail,
key: Key("collection" + c.thumbnail.tag()), shouldShowArchiveStatus: c.collection.isArchived(),
key: Key("collection" + c.thumbnail.tag(),),
)), )),
height: 140, height: 140,
width: 140, width: 140,

View file

@ -166,11 +166,11 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
), ),
); );
} }
if (widget.type == GalleryAppBarType.owned_collection && if (widget.type == GalleryAppBarType.owned_collection) {
widget.collection.type == CollectionType.album) {
actions.add(PopupMenuButton( actions.add(PopupMenuButton(
itemBuilder: (context) { itemBuilder: (context) {
final List<PopupMenuItem> items = []; final List<PopupMenuItem> items = [];
if (widget.collection.type == CollectionType.album) {
items.add( items.add(
PopupMenuItem( PopupMenuItem(
value: 1, value: 1,
@ -185,12 +185,36 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
), ),
), ),
); );
}
bool isArchived = widget.collection.isArchived();
items.add(
PopupMenuItem(
value: 2,
child: Row(
children: [
Icon(isArchived ? Icons.unarchive : Icons.archive),
Padding(
padding: EdgeInsets.all(8),
),
Text(isArchived ? "unarchive" : "archive"),
],
),
),
);
return items; return items;
}, },
onSelected: (value) async { onSelected: (value) async {
if (value == 1) { if (value == 1) {
await _renameAlbum(context); await _renameAlbum(context);
} }
if (value == 2) {
await changeCollectionVisibility(
context,
widget.collection,
widget.collection.isArchived()
? kVisibilityVisible
: kVisibilityArchive);
}
}, },
)); ));
} }

View file

@ -343,20 +343,28 @@ class _HomeWidgetState extends State<HomeWidget> {
asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async { asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async {
final importantPaths = Configuration.instance.getPathsToBackUp(); final importantPaths = Configuration.instance.getPathsToBackUp();
final ownerID = Configuration.instance.getUserID(); final ownerID = Configuration.instance.getUserID();
final archivedCollectionIds =
CollectionsService.instance.getArchivedCollections();
FileLoadResult result; FileLoadResult result;
if (importantPaths.isNotEmpty) { if (importantPaths.isNotEmpty) {
result = await FilesDB.instance.getImportantFiles(creationStartTime, result = await FilesDB.instance.getImportantFiles(creationStartTime,
creationEndTime, ownerID, importantPaths.toList(), creationEndTime, ownerID, importantPaths.toList(),
limit: limit, asc: asc); limit: limit,
asc: asc,
ignoredCollectionIDs: archivedCollectionIds);
} else { } else {
if (LocalSyncService.instance.hasGrantedLimitedPermissions()) { if (LocalSyncService.instance.hasGrantedLimitedPermissions()) {
result = await FilesDB.instance.getAllLocalAndUploadedFiles( result = await FilesDB.instance.getAllLocalAndUploadedFiles(
creationStartTime, creationEndTime, ownerID, creationStartTime, creationEndTime, ownerID,
limit: limit, asc: asc); limit: limit,
asc: asc,
ignoredCollectionIDs: archivedCollectionIds);
} else { } else {
result = await FilesDB.instance.getAllUploadedFiles( result = await FilesDB.instance.getAllUploadedFiles(
creationStartTime, creationEndTime, ownerID, creationStartTime, creationEndTime, ownerID,
limit: limit, asc: asc); limit: limit,
asc: asc,
ignoredCollectionIDs: archivedCollectionIds);
} }
} }
// hide ignored files from home page UI // hide ignored files from home page UI

View file

@ -35,7 +35,7 @@ class SocialSectionWidget extends StatelessWidget {
GestureDetector( GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () { onTap: () {
launch("https://discord.gg/uRqua3jSr5"); launch("https://ente.io/discord");
}, },
child: SettingsTextItem(text: "Discord", icon: Icons.navigate_next), child: SettingsTextItem(text: "Discord", icon: Icons.navigate_next),
), ),

View file

@ -21,6 +21,7 @@ class ThumbnailWidget extends StatefulWidget {
final File file; final File file;
final BoxFit fit; final BoxFit fit;
final bool shouldShowSyncStatus; final bool shouldShowSyncStatus;
final bool shouldShowArchiveStatus;
final bool shouldShowLivePhotoOverlay; final bool shouldShowLivePhotoOverlay;
final Duration diskLoadDeferDuration; final Duration diskLoadDeferDuration;
final Duration serverLoadDeferDuration; final Duration serverLoadDeferDuration;
@ -31,6 +32,7 @@ class ThumbnailWidget extends StatefulWidget {
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.shouldShowSyncStatus = true, this.shouldShowSyncStatus = true,
this.shouldShowLivePhotoOverlay = false, this.shouldShowLivePhotoOverlay = false,
this.shouldShowArchiveStatus = false,
this.diskLoadDeferDuration, this.diskLoadDeferDuration,
this.serverLoadDeferDuration, this.serverLoadDeferDuration,
}) : super(key: key ?? Key(file.tag())); }) : super(key: key ?? Key(file.tag()));
@ -74,6 +76,18 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
), ),
); );
static final kArchiveIconOverlay = Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 8, bottom: 8),
child: Icon(
Icons.archive_outlined,
size: 42,
color: Colors.white.withOpacity(0.9),
),
),
);
static final kUnsyncedIconOverlay = Container( static final kUnsyncedIconOverlay = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@ -173,8 +187,7 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
content = image; content = image;
} }
} }
return Stack( List<Widget> viewChildrens = [
children: [
loadingWidget, loadingWidget,
AnimatedOpacity( AnimatedOpacity(
opacity: content == null ? 0 : 1.0, opacity: content == null ? 0 : 1.0,
@ -184,7 +197,12 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
widget.shouldShowSyncStatus && widget.file.uploadedFileID == null widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
? kUnsyncedIconOverlay ? kUnsyncedIconOverlay
: getFileInfoContainer(widget.file), : getFileInfoContainer(widget.file),
], ];
if (widget.shouldShowArchiveStatus) {
viewChildrens.add(kArchiveIconOverlay);
}
return Stack(
children: viewChildrens,
fit: StackFit.expand, fit: StackFit.expand,
); );
} }

View file

@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/models/file.dart'; import 'package:photos/models/file.dart';
import 'package:photos/ui/thumbnail_widget.dart'; import 'package:photos/ui/thumbnail_widget.dart';
import 'package:photos/ui/video_controls.dart'; import 'package:photos/ui/video_controls.dart';
@ -118,6 +119,12 @@ class _VideoWidgetState extends State<VideoWidget> {
_videoPlayerController.value.isInitialized _videoPlayerController.value.isInitialized
? _getVideoPlayer() ? _getVideoPlayer()
: _getLoadingWidget(); : _getLoadingWidget();
final contentWithDetector = GestureDetector(
child: content,
onVerticalDragUpdate: (d) => {
if (d.delta.dy > kDragSensitivity) {Navigator.of(context).pop()}
},
);
return VisibilityDetector( return VisibilityDetector(
key: Key(widget.file.tag()), key: Key(widget.file.tag()),
onVisibilityChanged: (info) { onVisibilityChanged: (info) {
@ -129,7 +136,7 @@ class _VideoWidgetState extends State<VideoWidget> {
}, },
child: Hero( child: Hero(
tag: widget.tagPrefix + widget.file.tag(), tag: widget.tagPrefix + widget.file.tag(),
child: content, child: contentWithDetector,
), ),
); );
} }

View file

@ -43,6 +43,7 @@ class _ZoomableImageState extends State<ZoomableImage>
bool _loadingFinalImage = false; bool _loadingFinalImage = false;
bool _loadedFinalImage = false; bool _loadedFinalImage = false;
ValueChanged<PhotoViewScaleState> _scaleStateChangedCallback; ValueChanged<PhotoViewScaleState> _scaleStateChangedCallback;
bool _isZooming = false;
@override @override
void initState() { void initState() {
@ -51,6 +52,8 @@ class _ZoomableImageState extends State<ZoomableImage>
if (widget.shouldDisableScroll != null) { if (widget.shouldDisableScroll != null) {
widget.shouldDisableScroll(value != PhotoViewScaleState.initial); widget.shouldDisableScroll(value != PhotoViewScaleState.initial);
} }
_isZooming = value != PhotoViewScaleState.initial;
// _logger.info('is reakky zooming $_isZooming with state $value');
}; };
super.initState(); super.initState();
} }
@ -62,9 +65,10 @@ class _ZoomableImageState extends State<ZoomableImage>
} else { } else {
_loadLocalImage(context); _loadLocalImage(context);
} }
Widget content;
if (_imageProvider != null) { if (_imageProvider != null) {
return PhotoView( content = PhotoView(
imageProvider: _imageProvider, imageProvider: _imageProvider,
scaleStateChangedCallback: _scaleStateChangedCallback, scaleStateChangedCallback: _scaleStateChangedCallback,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
@ -75,8 +79,19 @@ class _ZoomableImageState extends State<ZoomableImage>
backgroundDecoration: widget.backgroundDecoration, backgroundDecoration: widget.backgroundDecoration,
); );
} else { } else {
return loadWidget; content = loadWidget;
} }
GestureDragUpdateCallback verticalDragCallback = _isZooming
? null
: (d) => {
if (!_isZooming && d.delta.dy > kDragSensitivity)
{Navigator.of(context).pop()}
};
return GestureDetector(
child: content,
onVerticalDragUpdate: verticalDragCallback,
);
} }
void _loadNetworkImage() { void _loadNetworkImage() {

View file

@ -4,8 +4,10 @@ import 'package:logging/logging.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:photos/core/event_bus.dart'; import 'package:photos/core/event_bus.dart';
import 'package:photos/events/force_reload_home_gallery_event.dart'; import 'package:photos/events/force_reload_home_gallery_event.dart';
import 'package:photos/models/collection.dart';
import 'package:photos/models/file.dart'; import 'package:photos/models/file.dart';
import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/magic_metadata.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/services/file_magic_service.dart'; import 'package:photos/services/file_magic_service.dart';
import 'package:photos/ui/rename_dialog.dart'; import 'package:photos/ui/rename_dialog.dart';
import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/dialog_util.dart';
@ -32,6 +34,28 @@ Future<void> changeVisibility(
} }
} }
Future<void> changeCollectionVisibility(
BuildContext context, Collection collection, int newVisibility) async {
final dialog = createProgressDialog(context,
newVisibility == kVisibilityArchive ? "archiving..." : "unarchiving...");
await dialog.show();
try {
Map<String, dynamic> update = {kMagicKeyVisibility: newVisibility};
await CollectionsService.instance.updateMagicMetadata(collection, update);
// Force reload home gallery to pull in the now unarchived files
Bus.instance.fire(ForceReloadHomeGalleryEvent());
showShortToast(newVisibility == kVisibilityArchive
? "successfully archived"
: "successfully unarchived");
await dialog.hide();
} catch (e, s) {
_logger.severe("failed to update collection visibility", e, s);
await dialog.hide();
rethrow;
}
}
Future<bool> editTime( Future<bool> editTime(
BuildContext context, List<File> files, int editedTime) async { BuildContext context, List<File> files, int editedTime) async {
try { try {

View file

@ -11,7 +11,7 @@ description: ente photos application
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.5.10+290 version: 0.5.11+291
environment: environment:
sdk: ">=2.10.0 <3.0.0" sdk: ">=2.10.0 <3.0.0"