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,22 +68,23 @@ class Collection {
} }
} }
Collection copyWith({ Collection copyWith(
int id, {int id,
User owner, User owner,
String encryptedKey, String encryptedKey,
String keyDecryptionNonce, String keyDecryptionNonce,
String name, String name,
String encryptedName, String encryptedName,
String nameDecryptionNonce, String nameDecryptionNonce,
CollectionType type, CollectionType type,
CollectionAttributes attributes, CollectionAttributes attributes,
List<User> sharees, List<User> sharees,
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,21 +166,37 @@ 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(
PopupMenuItem(
value: 1,
child: Row(
children: const [
Icon(Icons.edit),
Padding(
padding: EdgeInsets.all(8),
),
Text("rename"),
],
),
),
);
}
bool isArchived = widget.collection.isArchived();
items.add( items.add(
PopupMenuItem( PopupMenuItem(
value: 1, value: 2,
child: Row( child: Row(
children: const [ children: [
Icon(Icons.edit), Icon(isArchived ? Icons.unarchive : Icons.archive),
Padding( Padding(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
), ),
Text("rename"), Text(isArchived ? "unarchive" : "archive"),
], ],
), ),
), ),
@ -191,6 +207,14 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
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,18 +187,22 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
content = image; content = image;
} }
} }
List<Widget> viewChildrens = [
loadingWidget,
AnimatedOpacity(
opacity: content == null ? 0 : 1.0,
duration: Duration(milliseconds: 200),
child: content,
),
widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
? kUnsyncedIconOverlay
: getFileInfoContainer(widget.file),
];
if (widget.shouldShowArchiveStatus) {
viewChildrens.add(kArchiveIconOverlay);
}
return Stack( return Stack(
children: [ children: viewChildrens,
loadingWidget,
AnimatedOpacity(
opacity: content == null ? 0 : 1.0,
duration: Duration(milliseconds: 200),
child: content,
),
widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
? kUnsyncedIconOverlay
: getFileInfoContainer(widget.file),
],
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"