Merge remote-tracking branch 'origin/theme' into frame-ashil-neeraj
This commit is contained in:
commit
70855cb927
30 changed files with 1052 additions and 120 deletions
Binary file not shown.
55
lib/app.dart
55
lib/app.dart
|
@ -30,6 +30,7 @@ final lightThemeData = ThemeData(
|
|||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(50, 16, 50, 16),
|
||||
alignment: Alignment.center,
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
|
@ -57,14 +58,20 @@ final lightThemeData = ThemeData(
|
|||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
onPrimary: Colors.white,
|
||||
primary: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
alignment: Alignment.center,
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
fontSize: 18,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
)),
|
||||
|
||||
toggleableActiveColor: Colors.red[400],
|
||||
scaffoldBackgroundColor: Colors.white,
|
||||
bottomAppBarColor: Color.fromRGBO(196, 196, 196, 1.0),
|
||||
|
@ -75,9 +82,17 @@ final lightThemeData = ThemeData(
|
|||
//https://api.flutter.dev/flutter/material/TextTheme-class.html
|
||||
textTheme: TextTheme().copyWith(
|
||||
headline4: TextStyle(
|
||||
color: Colors.black, fontSize: 32, fontWeight: FontWeight.w600),
|
||||
color: Colors.black,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
),
|
||||
headline5: TextStyle(
|
||||
color: Colors.black, fontSize: 24, fontWeight: FontWeight.w600),
|
||||
color: Colors.black,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
),
|
||||
headline6: TextStyle(
|
||||
color: Colors.black, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
subtitle1: TextStyle(
|
||||
|
@ -85,6 +100,11 @@ final lightThemeData = ThemeData(
|
|||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
bodyText1: TextStyle(
|
||||
fontFamily: 'Inter-Medium',
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400),
|
||||
caption: TextStyle(color: Colors.black.withOpacity(0.7), fontSize: 14),
|
||||
overline: TextStyle(color: Colors.black.withOpacity(0.8), fontSize: 12)),
|
||||
|
||||
|
@ -151,9 +171,17 @@ final darkThemeData = ThemeData(
|
|||
// primaryColor: Colors.red,
|
||||
textTheme: TextTheme().copyWith(
|
||||
headline4: TextStyle(
|
||||
color: Colors.white, fontSize: 32, fontWeight: FontWeight.w600),
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
),
|
||||
headline5: TextStyle(
|
||||
color: Colors.white, fontSize: 24, fontWeight: FontWeight.w600),
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
),
|
||||
headline6: TextStyle(
|
||||
color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
subtitle1: TextStyle(
|
||||
|
@ -161,6 +189,11 @@ final darkThemeData = ThemeData(
|
|||
fontFamily: 'Inter-Medium',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
bodyText1: TextStyle(
|
||||
fontFamily: 'Inter-Medium',
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400),
|
||||
caption: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
|
@ -175,6 +208,7 @@ final darkThemeData = ThemeData(
|
|||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.fromLTRB(50, 16, 50, 16),
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
|
@ -203,14 +237,19 @@ final darkThemeData = ThemeData(
|
|||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.grey[700],
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPrimary: Colors.black,
|
||||
primary: Colors.white,
|
||||
minimumSize: Size(88, 36),
|
||||
alignment: Alignment.center,
|
||||
textStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
fontSize: 18,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
)),
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
backgroundColor: Colors.black,
|
||||
|
|
|
@ -75,7 +75,7 @@ class Configuration {
|
|||
String _volatilePassword;
|
||||
|
||||
final _secureStorageOptionsIOS =
|
||||
IOSOptions(accessibility: IOSAccessibility.first_unlock_this_device);
|
||||
IOSOptions(accessibility: IOSAccessibility.first_unlock);
|
||||
|
||||
Future<void> init() async {
|
||||
_preferences = await SharedPreferences.getInstance();
|
||||
|
|
|
@ -24,3 +24,6 @@ const kThumbnailServerLoadDeferDuration = Duration(milliseconds: 80);
|
|||
// 256 bit key maps to 24 words
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#Generating_the_mnemonic
|
||||
const kMnemonicKeyWordCount = 24;
|
||||
|
||||
// https://stackoverflow.com/a/61162219
|
||||
const kDragSensitivity = 8;
|
||||
|
|
|
@ -27,6 +27,9 @@ class CollectionsDB {
|
|||
static final columnVersion = 'version';
|
||||
static final columnSharees = 'sharees';
|
||||
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 columnIsDeleted = 'is_deleted';
|
||||
|
||||
|
@ -37,6 +40,7 @@ class CollectionsDB {
|
|||
...addVersion(),
|
||||
...addIsDeleted(),
|
||||
...addPublicURLs(),
|
||||
...addPrivateMetadata(),
|
||||
];
|
||||
|
||||
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 {
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
|
@ -204,11 +219,13 @@ class CollectionsDB {
|
|||
} else {
|
||||
row[columnIsDeleted] = _sqlBoolFalse;
|
||||
}
|
||||
row[columnMMdVersion] = collection.mMdVersion ?? 0;
|
||||
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
|
||||
Collection _convertToCollection(Map<String, dynamic> row) {
|
||||
return Collection(
|
||||
Collection result = Collection(
|
||||
row[columnID],
|
||||
User.fromJson(row[columnOwner]),
|
||||
row[columnEncryptedKey],
|
||||
|
@ -232,5 +249,8 @@ class CollectionsDB {
|
|||
// default to False is columnIsDeleted is not set
|
||||
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
);
|
||||
result.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -399,7 +399,10 @@ class FilesDB {
|
|||
|
||||
Future<FileLoadResult> getAllUploadedFiles(
|
||||
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 order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
|
@ -413,13 +416,14 @@ class FilesDB {
|
|||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
List<File> deduplicatedFiles = _deduplicatedFiles(files);
|
||||
List<File> deduplicatedFiles =
|
||||
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
|
||||
return FileLoadResult(deduplicatedFiles, files.length == limit);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getAllLocalAndUploadedFiles(
|
||||
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 order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
|
@ -433,13 +437,14 @@ class FilesDB {
|
|||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
List<File> deduplicatedFiles = _deduplicatedFiles(files);
|
||||
List<File> deduplicatedFiles =
|
||||
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
|
||||
return FileLoadResult(deduplicatedFiles, files.length == limit);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getImportantFiles(
|
||||
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;
|
||||
String inParam = "";
|
||||
for (final path in paths) {
|
||||
|
@ -458,15 +463,21 @@ class FilesDB {
|
|||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
List<File> deduplicatedFiles = _deduplicatedFiles(files);
|
||||
List<File> deduplicatedFiles =
|
||||
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
|
||||
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 List<File> deduplicatedFiles = [];
|
||||
for (final file in files) {
|
||||
final id = file.uploadedFileID;
|
||||
if (ignoredCollectionIDs != null &&
|
||||
ignoredCollectionIDs.contains(file.collectionID)) {
|
||||
continue;
|
||||
}
|
||||
if (id != null && id != -1 && uploadedFileIDs.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:photos/models/magic_metadata.dart';
|
||||
|
||||
class Collection {
|
||||
final int id;
|
||||
|
@ -16,6 +18,14 @@ class Collection {
|
|||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
String mMdEncodedJson;
|
||||
int mMdVersion = 0;
|
||||
CollectionMagicMetadata _mmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
set magicMetadata(val) => _mmd = val;
|
||||
|
||||
Collection(
|
||||
this.id,
|
||||
|
@ -33,6 +43,10 @@ class Collection {
|
|||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == kVisibilityArchive;
|
||||
}
|
||||
|
||||
static CollectionType typeFromString(String type) {
|
||||
switch (type) {
|
||||
case "folder":
|
||||
|
@ -54,22 +68,23 @@ class Collection {
|
|||
}
|
||||
}
|
||||
|
||||
Collection copyWith({
|
||||
int id,
|
||||
User owner,
|
||||
String encryptedKey,
|
||||
String keyDecryptionNonce,
|
||||
String name,
|
||||
String encryptedName,
|
||||
String nameDecryptionNonce,
|
||||
CollectionType type,
|
||||
CollectionAttributes attributes,
|
||||
List<User> sharees,
|
||||
List<PublicURL> publicURLs,
|
||||
int updationTime,
|
||||
bool isDeleted,
|
||||
}) {
|
||||
return Collection(
|
||||
Collection copyWith(
|
||||
{int id,
|
||||
User owner,
|
||||
String encryptedKey,
|
||||
String keyDecryptionNonce,
|
||||
String name,
|
||||
String encryptedName,
|
||||
String nameDecryptionNonce,
|
||||
CollectionType type,
|
||||
CollectionAttributes attributes,
|
||||
List<User> sharees,
|
||||
List<PublicURL> publicURLs,
|
||||
int updationTime,
|
||||
bool isDeleted,
|
||||
String mMdEncodedJson,
|
||||
int mMdVersion}) {
|
||||
Collection result = Collection(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
|
@ -84,6 +99,9 @@ class Collection {
|
|||
updationTime ?? this.updationTime,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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_file_item.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/file_magic_service.dart';
|
||||
import 'package:photos/services/remote_sync_service.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/file_download_util.dart';
|
||||
|
@ -27,7 +30,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
|
||||
class CollectionsService {
|
||||
static final _collectionSyncTimeKeyPrefix = "collection_sync_time_";
|
||||
static final _collectionsSyncTimeKey = "collections_sync_time";
|
||||
static final _collectionsSyncTimeKey = "collections_sync_time_x";
|
||||
|
||||
static const int kMaximumWriteAttempts = 5;
|
||||
|
||||
|
@ -55,6 +58,7 @@ class CollectionsService {
|
|||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
final collections = await _db.getAllCollections();
|
||||
|
||||
for (final collection in collections) {
|
||||
_cacheCollectionAttributes(collection);
|
||||
}
|
||||
|
@ -74,8 +78,7 @@ class CollectionsService {
|
|||
_prefs.getInt(_collectionsSyncTimeKey) ?? 0;
|
||||
|
||||
// Might not have synced the collection fully
|
||||
final fetchedCollections =
|
||||
await _fetchCollections(lastCollectionUpdationTime ?? 0);
|
||||
final fetchedCollections = await _fetchCollections(0);
|
||||
final updatedCollections = <Collection>[];
|
||||
int maxUpdationTime = lastCollectionUpdationTime;
|
||||
final ownerID = _config.getUserID();
|
||||
|
@ -127,6 +130,14 @@ class CollectionsService {
|
|||
return updatedCollections;
|
||||
}
|
||||
|
||||
Set<int> getArchivedCollections() {
|
||||
return _collectionIDToCollections.values
|
||||
.toList()
|
||||
.where((element) => element.isArchived())
|
||||
.map((e) => e.id)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
int getCollectionSyncTime(int collectionID) {
|
||||
return _prefs
|
||||
.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 {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
|
@ -361,8 +430,20 @@ class CollectionsService {
|
|||
final List<Collection> collections = [];
|
||||
if (response != null) {
|
||||
final c = response.data["collections"];
|
||||
for (final collection in c) {
|
||||
collections.add(Collection.fromMap(collection));
|
||||
for (final collectionData in c) {
|
||||
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;
|
||||
|
@ -408,7 +489,19 @@ class CollectionsService {
|
|||
headers: {"X-Auth-Token": Configuration.instance.getToken()}),
|
||||
);
|
||||
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]));
|
||||
_cacheCollectionAttributes(collection);
|
||||
return collection;
|
||||
|
|
|
@ -217,7 +217,7 @@ class RemoteSyncService {
|
|||
// uploaded yet. These files should ignore video backup & ignored files filter
|
||||
filesToBeUploaded = await _db.getPendingManualUploads();
|
||||
}
|
||||
_moveVideosToEnd(filesToBeUploaded);
|
||||
_sortByTimeAndType(filesToBeUploaded);
|
||||
_logger.info(
|
||||
filesToBeUploaded.length.toString() + " new files to be uploaded.");
|
||||
return filesToBeUploaded;
|
||||
|
@ -453,10 +453,12 @@ class RemoteSyncService {
|
|||
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) {
|
||||
if (first.fileType == second.fileType) {
|
||||
return 0;
|
||||
return second.creationTime.compareTo(first.creationTime);
|
||||
} else if (first.fileType == FileType.video) {
|
||||
return 1;
|
||||
} else {
|
||||
|
|
|
@ -10,7 +10,6 @@ import 'package:photos/db/files_db.dart';
|
|||
import 'package:photos/events/backup_folders_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/ui/common/custom_color_scheme.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
|
||||
|
@ -133,31 +132,19 @@ class _BackupFolderSelectionPageState extends State<BackupFolderSelectionPage> {
|
|||
tag: "select_folders",
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
// padding: EdgeInsets.only(
|
||||
// left: 20, right: 20, bottom: Platform.isIOS ? 60 : 32),
|
||||
padding: EdgeInsets.only(left: 20, right: 20, bottom: 40),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).backgroundColor,
|
||||
spreadRadius: 175,
|
||||
blurRadius: 50,
|
||||
offset: Offset(0, 150),
|
||||
)
|
||||
]),
|
||||
child: OutlinedButton(
|
||||
child: Text(widget.buttonText),
|
||||
onPressed: _selectedFolders.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
await Configuration.instance
|
||||
.setPathsToBackUp(_selectedFolders);
|
||||
Bus.instance.fire(BackupFoldersUpdatedEvent());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
// padding: EdgeInsets.fromLTRB(12, 20, 12, 20),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 24, right: 24, bottom: Platform.isIOS ? 60 : 32),
|
||||
child: OutlinedButton(
|
||||
child: Text(widget.buttonText),
|
||||
onPressed: _selectedFolders.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
await Configuration.instance
|
||||
.setPathsToBackUp(_selectedFolders);
|
||||
Bus.instance.fire(BackupFoldersUpdatedEvent());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
// padding: EdgeInsets.fromLTRB(12, 20, 12, 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -487,7 +487,8 @@ class CollectionItem extends StatelessWidget {
|
|||
tag: "collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(
|
||||
c.thumbnail,
|
||||
key: Key("collection" + c.thumbnail.tag()),
|
||||
shouldShowArchiveStatus: c.collection.isArchived(),
|
||||
key: Key("collection" + c.thumbnail.tag(),),
|
||||
)),
|
||||
height: 140,
|
||||
width: 140,
|
||||
|
|
|
@ -19,4 +19,36 @@ extension CustomColorScheme on ColorScheme {
|
|||
|
||||
Color get fabTextOrIconColor =>
|
||||
brightness == Brightness.light ? Colors.white : Colors.white;
|
||||
|
||||
ButtonStyle get primaryActionButtonStyle => ElevatedButton.styleFrom(
|
||||
onPrimary: Colors.white,
|
||||
primary: Color.fromRGBO(29, 185, 84, 1.0),
|
||||
minimumSize: Size(88, 36),
|
||||
alignment: Alignment.center,
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
fontSize: 18,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
);
|
||||
|
||||
ButtonStyle get optionalActionButtonStyle => ElevatedButton.styleFrom(
|
||||
onPrimary: Colors.black87,
|
||||
primary: Color.fromRGBO(240, 240, 240, 1),
|
||||
minimumSize: Size(88, 36),
|
||||
alignment: Alignment.center,
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter-SemiBold',
|
||||
fontSize: 18,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -166,21 +166,37 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (widget.type == GalleryAppBarType.owned_collection &&
|
||||
widget.collection.type == CollectionType.album) {
|
||||
if (widget.type == GalleryAppBarType.owned_collection) {
|
||||
actions.add(PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
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(
|
||||
PopupMenuItem(
|
||||
value: 1,
|
||||
value: 2,
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.edit),
|
||||
children: [
|
||||
Icon(isArchived ? Icons.unarchive : Icons.archive),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text("rename"),
|
||||
Text(isArchived ? "unarchive" : "archive"),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -191,6 +207,14 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
if (value == 1) {
|
||||
await _renameAlbum(context);
|
||||
}
|
||||
if (value == 2) {
|
||||
await changeCollectionVisibility(
|
||||
context,
|
||||
widget.collection,
|
||||
widget.collection.isArchived()
|
||||
? kVisibilityVisible
|
||||
: kVisibilityArchive);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
|
|
@ -343,20 +343,28 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async {
|
||||
final importantPaths = Configuration.instance.getPathsToBackUp();
|
||||
final ownerID = Configuration.instance.getUserID();
|
||||
final archivedCollectionIds =
|
||||
CollectionsService.instance.getArchivedCollections();
|
||||
FileLoadResult result;
|
||||
if (importantPaths.isNotEmpty) {
|
||||
result = await FilesDB.instance.getImportantFiles(creationStartTime,
|
||||
creationEndTime, ownerID, importantPaths.toList(),
|
||||
limit: limit, asc: asc);
|
||||
limit: limit,
|
||||
asc: asc,
|
||||
ignoredCollectionIDs: archivedCollectionIds);
|
||||
} else {
|
||||
if (LocalSyncService.instance.hasGrantedLimitedPermissions()) {
|
||||
result = await FilesDB.instance.getAllLocalAndUploadedFiles(
|
||||
creationStartTime, creationEndTime, ownerID,
|
||||
limit: limit, asc: asc);
|
||||
limit: limit,
|
||||
asc: asc,
|
||||
ignoredCollectionIDs: archivedCollectionIds);
|
||||
} else {
|
||||
result = await FilesDB.instance.getAllUploadedFiles(
|
||||
creationStartTime, creationEndTime, ownerID,
|
||||
limit: limit, asc: asc);
|
||||
limit: limit,
|
||||
asc: asc,
|
||||
ignoredCollectionIDs: archivedCollectionIds);
|
||||
}
|
||||
}
|
||||
// hide ignored files from home page UI
|
||||
|
|
228
lib/ui/recovery_key_page.dart
Normal file
228
lib/ui/recovery_key_page.dart
Normal file
|
@ -0,0 +1,228 @@
|
|||
import 'dart:io' as io;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bip39/bip39.dart' as bip39;
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/ui/common/custom_color_scheme.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class RecoveryKeyPage extends StatefulWidget {
|
||||
final bool showAppBar;
|
||||
final String recoveryKey;
|
||||
final String doneText;
|
||||
final Function() onDone;
|
||||
final bool isDismissible;
|
||||
final String title;
|
||||
final String text;
|
||||
final String subText;
|
||||
|
||||
const RecoveryKeyPage(this.recoveryKey, this.doneText,
|
||||
{Key key,
|
||||
this.showAppBar,
|
||||
this.onDone,
|
||||
this.isDismissible,
|
||||
this.title,
|
||||
this.text,
|
||||
this.subText})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_RecoveryKeyPageState createState() => _RecoveryKeyPageState();
|
||||
}
|
||||
|
||||
class _RecoveryKeyPageState extends State<RecoveryKeyPage> {
|
||||
bool _hasTriedToSave = false;
|
||||
final _recoveryKeyFile = io.File(
|
||||
Configuration.instance.getTempDirectory() + "ente-recovery-key.txt");
|
||||
final _recoveryKey = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String recoveryKey = bip39.entropyToMnemonic(widget.recoveryKey);
|
||||
if (recoveryKey
|
||||
.split(' ')
|
||||
.length != kMnemonicKeyWordCount) {
|
||||
throw AssertionError(
|
||||
'recovery code should have $kMnemonicKeyWordCount words');
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: widget.showAppBar
|
||||
? AppBar(
|
||||
title: Text(""),
|
||||
)
|
||||
: null,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 40, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Text(widget.title ?? "Recovery Key",
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.headline4),
|
||||
Padding(padding: EdgeInsets.all(12)),
|
||||
Text(
|
||||
widget.text ??
|
||||
"If you forget your password, the only way you can recover your data is with this key.",
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.subtitle1,
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(top: 24)),
|
||||
DottedBorder(
|
||||
color: Color.fromRGBO(17, 127, 56, 1),
|
||||
//color of dotted/dash line
|
||||
strokeWidth: 1,
|
||||
//thickness of dash/dots
|
||||
dashPattern: const [6, 6],
|
||||
radius: Radius.circular(8),
|
||||
//dash patterns, 10 is dash width, 6 is space width
|
||||
child: SizedBox(
|
||||
//inner container
|
||||
height: 200, //height of inner container
|
||||
width:
|
||||
double.infinity, //width to 100% match to parent container.
|
||||
// ignore: prefer_const_literals_to_create_immutables
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(
|
||||
text: recoveryKey));
|
||||
showToast("recovery key copied to clipboard");
|
||||
setState(() {
|
||||
_hasTriedToSave = true;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Color.fromRGBO(49, 155, 86, .2),
|
||||
),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
color: Color.fromRGBO(49, 155, 86, .2),
|
||||
),
|
||||
// color: Color.fromRGBO(49, 155, 86, .2),
|
||||
height: 120,
|
||||
padding: EdgeInsets.all(20),
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
recoveryKey,
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyText1,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
child: Text(
|
||||
widget.subText ??
|
||||
"we don’t store this key, please save this in a safe place",
|
||||
style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyText1,
|
||||
),
|
||||
padding: EdgeInsets.all(20)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(10, 10, 10, 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _saveOptions(context, recoveryKey)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _saveOptions(BuildContext context, String recoveryKey) {
|
||||
List<Widget> childrens = [];
|
||||
if (!_hasTriedToSave) {
|
||||
childrens.add(ElevatedButton(
|
||||
child: Text('Save Later'),
|
||||
style: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.optionalActionButtonStyle,
|
||||
onPressed: () async {
|
||||
await _saveKeys();
|
||||
},
|
||||
));
|
||||
childrens.add(SizedBox(height: 10));
|
||||
}
|
||||
|
||||
childrens.add(ElevatedButton(
|
||||
child: Text('Save'),
|
||||
style: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.primaryActionButtonStyle,
|
||||
onPressed: () async {
|
||||
await _shareRecoveryKey(recoveryKey);
|
||||
},
|
||||
));
|
||||
if (_hasTriedToSave) {
|
||||
childrens.add(SizedBox(height: 10));
|
||||
childrens.add(ElevatedButton(
|
||||
child: Text(widget.doneText),
|
||||
// style: Theme.of(context).colorScheme.primaryActionButtonStyle,
|
||||
onPressed: () async {
|
||||
await _saveKeys();
|
||||
},
|
||||
));
|
||||
}
|
||||
childrens.add(SizedBox(height: 12));
|
||||
return childrens;
|
||||
}
|
||||
|
||||
Future _shareRecoveryKey(String recoveryKey) async {
|
||||
if (_recoveryKeyFile.existsSync()) {
|
||||
await _recoveryKeyFile.delete();
|
||||
}
|
||||
_recoveryKeyFile.writeAsStringSync(recoveryKey);
|
||||
await Share.shareFiles([_recoveryKeyFile.path]);
|
||||
Future.delayed(Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasTriedToSave = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveKeys() async {
|
||||
Navigator.of(context).pop();
|
||||
if (_recoveryKeyFile.existsSync()) {
|
||||
await _recoveryKeyFile.delete();
|
||||
}
|
||||
widget.onDone();
|
||||
}
|
||||
}
|
|
@ -8,12 +8,13 @@ import 'package:photos/ui/app_lock.dart';
|
|||
import 'package:photos/ui/change_email_dialog.dart';
|
||||
import 'package:photos/ui/password_entry_page.dart';
|
||||
import 'package:photos/ui/payment/subscription.dart';
|
||||
import 'package:photos/ui/recovery_key_dialog.dart';
|
||||
import 'package:photos/ui/recovery_key_page.dart';
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import 'package:photos/ui/settings/settings_section_title.dart';
|
||||
import 'package:photos/ui/settings/settings_text_item.dart';
|
||||
import 'package:photos/utils/auth_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
||||
class AccountSectionWidget extends StatefulWidget {
|
||||
|
@ -72,17 +73,13 @@ class AccountSectionWidgetState extends State<AccountSectionWidget> {
|
|||
showGenericErrorDialog(context);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return RecoveryKeyDialog(recoveryKey, "ok", () {});
|
||||
},
|
||||
barrierColor: Colors.black.withOpacity(0.85),
|
||||
);
|
||||
routeToPage(
|
||||
context,
|
||||
RecoveryKeyPage(recoveryKey, "OK",
|
||||
showAppBar: true, onDone: () {}));
|
||||
},
|
||||
child:
|
||||
SettingsTextItem(text: "Recovery key", icon: Icons.navigate_next),
|
||||
SettingsTextItem(text: "Recovery Key", icon: Icons.navigate_next),
|
||||
),
|
||||
SectionOptionDivider,
|
||||
GestureDetector(
|
||||
|
|
|
@ -59,7 +59,10 @@ class BackupSectionWidgetState extends State<BackupSectionWidget> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Backup over mobile data", style: Theme.of(context).textTheme.subtitle1,),
|
||||
Text(
|
||||
"Backup over mobile data",
|
||||
style: Theme.of(context).textTheme.subtitle1,
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: Configuration.instance.shouldBackupOverMobileData(),
|
||||
onChanged: (value) async {
|
||||
|
@ -76,7 +79,10 @@ class BackupSectionWidgetState extends State<BackupSectionWidget> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Backup videos", style: Theme.of(context).textTheme.subtitle1,),
|
||||
Text(
|
||||
"Backup videos",
|
||||
style: Theme.of(context).textTheme.subtitle1,
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: Configuration.instance.shouldBackupVideos(),
|
||||
onChanged: (value) async {
|
||||
|
|
|
@ -35,7 +35,7 @@ class SocialSectionWidget extends StatelessWidget {
|
|||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
launch("https://discord.gg/uRqua3jSr5");
|
||||
launch("https://ente.io/discord");
|
||||
},
|
||||
child: SettingsTextItem(text: "Discord", icon: Icons.navigate_next),
|
||||
),
|
||||
|
|
224
lib/ui/settings/usage_details_widget.dart
Normal file
224
lib/ui/settings/usage_details_widget.dart
Normal file
|
@ -0,0 +1,224 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class UsageDetailsWidget extends StatelessWidget {
|
||||
const UsageDetailsWidget({Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 343,
|
||||
height: 196,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
color: Color(0xff42b96c),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Container(
|
||||
width: 343,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 343,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Color(0xff42b96c),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Opacity(
|
||||
opacity: 0.20,
|
||||
child: Container(
|
||||
width: 306,
|
||||
height: 266,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 1,
|
||||
),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Opacity(
|
||||
opacity: 0.20,
|
||||
child: Container(
|
||||
width: 376,
|
||||
height: 256,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Container(
|
||||
width: 229,
|
||||
height: 226,
|
||||
child: FlutterLogo(size: 226),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Opacity(
|
||||
opacity: 0.20,
|
||||
child: Container(
|
||||
width: 306,
|
||||
height: 336,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Container(
|
||||
width: 343,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Color(0x33000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 20,
|
||||
child: Text(
|
||||
"Current plan",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 224,
|
||||
top: 20,
|
||||
child: Opacity(
|
||||
opacity: 0.50,
|
||||
child: Text(
|
||||
"Ends 22 Jan’23",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 40,
|
||||
child: Text(
|
||||
"10 GB",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontFamily: "SF Pro Display",
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 136,
|
||||
child: SizedBox(
|
||||
width: 311,
|
||||
height: 36,
|
||||
child: Column(
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: 0.4,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).buttonColor),
|
||||
),
|
||||
Padding(padding: EdgeInsets.fromLTRB(0, 12, 0, 0)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
Text(
|
||||
"2.3 GB of 10 GB used",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"3,846 Memories",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/ui/settings/account_section_widget.dart';
|
||||
import 'package:photos/ui/settings/app_version_widget.dart';
|
||||
|
@ -13,6 +12,7 @@ import 'package:photos/ui/settings/security_section_widget.dart';
|
|||
import 'package:photos/ui/settings/social_section_widget.dart';
|
||||
import 'package:photos/ui/settings/support_section_widget.dart';
|
||||
import 'package:photos/ui/settings/theme_switch_widget.dart';
|
||||
import 'package:photos/ui/settings/usage_details_widget.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({Key key}) : super(key: key);
|
||||
|
@ -39,6 +39,8 @@ class SettingsPage extends StatelessWidget {
|
|||
contents.addAll([
|
||||
DetailsSectionWidget(),
|
||||
sectionDivider,
|
||||
UsageDetailsWidget(),
|
||||
sectionDivider,
|
||||
BackupSectionWidget(),
|
||||
sectionDivider,
|
||||
AccountSectionWidget(),
|
||||
|
|
|
@ -21,6 +21,7 @@ class ThumbnailWidget extends StatefulWidget {
|
|||
final File file;
|
||||
final BoxFit fit;
|
||||
final bool shouldShowSyncStatus;
|
||||
final bool shouldShowArchiveStatus;
|
||||
final bool shouldShowLivePhotoOverlay;
|
||||
final Duration diskLoadDeferDuration;
|
||||
final Duration serverLoadDeferDuration;
|
||||
|
@ -31,6 +32,7 @@ class ThumbnailWidget extends StatefulWidget {
|
|||
this.fit = BoxFit.cover,
|
||||
this.shouldShowSyncStatus = true,
|
||||
this.shouldShowLivePhotoOverlay = false,
|
||||
this.shouldShowArchiveStatus = false,
|
||||
this.diskLoadDeferDuration,
|
||||
this.serverLoadDeferDuration,
|
||||
}) : 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(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
|
@ -173,18 +187,22 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
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(
|
||||
children: [
|
||||
loadingWidget,
|
||||
AnimatedOpacity(
|
||||
opacity: content == null ? 0 : 1.0,
|
||||
duration: Duration(milliseconds: 200),
|
||||
child: content,
|
||||
),
|
||||
widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
|
||||
? kUnsyncedIconOverlay
|
||||
: getFileInfoContainer(widget.file),
|
||||
],
|
||||
children: viewChildrens,
|
||||
fit: StackFit.expand,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@ import 'package:photos/core/configuration.dart';
|
|||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/ui/lifecycle_event_handler.dart';
|
||||
import 'package:photos/ui/recovery_key_dialog.dart';
|
||||
import 'package:photos/ui/recovery_key_page.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
import 'package:pinput/pin_put/pin_put.dart';
|
||||
|
||||
|
@ -264,26 +265,23 @@ class _TwoFactorSetupPageState extends State<TwoFactorSetupPage>
|
|||
final success = await UserService.instance
|
||||
.enableTwoFactor(context, widget.secretCode, code);
|
||||
if (success) {
|
||||
_showSuccessDialog();
|
||||
_showSuccessPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuccessDialog() {
|
||||
void _showSuccessPage() {
|
||||
final recoveryKey = Sodium.bin2hex(Configuration.instance.getRecoveryKey());
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return RecoveryKeyDialog(
|
||||
routeToPage(
|
||||
context,
|
||||
RecoveryKeyPage(
|
||||
recoveryKey,
|
||||
"ok",
|
||||
() {},
|
||||
"OK",
|
||||
showAppBar: true,
|
||||
onDone: () {},
|
||||
title: "⚡ setup complete",
|
||||
text: "save your recovery key if you haven't already",
|
||||
subText:
|
||||
"this can be used to recover your account if you lose your second factor",
|
||||
);
|
||||
},
|
||||
barrierColor: Colors.black.withOpacity(0.85),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
import 'package:photos/ui/video_controls.dart';
|
||||
|
@ -118,6 +119,12 @@ class _VideoWidgetState extends State<VideoWidget> {
|
|||
_videoPlayerController.value.isInitialized
|
||||
? _getVideoPlayer()
|
||||
: _getLoadingWidget();
|
||||
final contentWithDetector = GestureDetector(
|
||||
child: content,
|
||||
onVerticalDragUpdate: (d) => {
|
||||
if (d.delta.dy > kDragSensitivity) {Navigator.of(context).pop()}
|
||||
},
|
||||
);
|
||||
return VisibilityDetector(
|
||||
key: Key(widget.file.tag()),
|
||||
onVisibilityChanged: (info) {
|
||||
|
@ -129,7 +136,7 @@ class _VideoWidgetState extends State<VideoWidget> {
|
|||
},
|
||||
child: Hero(
|
||||
tag: widget.tagPrefix + widget.file.tag(),
|
||||
child: content,
|
||||
child: contentWithDetector,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ class _ZoomableImageState extends State<ZoomableImage>
|
|||
bool _loadingFinalImage = false;
|
||||
bool _loadedFinalImage = false;
|
||||
ValueChanged<PhotoViewScaleState> _scaleStateChangedCallback;
|
||||
bool _isZooming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -51,6 +52,8 @@ class _ZoomableImageState extends State<ZoomableImage>
|
|||
if (widget.shouldDisableScroll != null) {
|
||||
widget.shouldDisableScroll(value != PhotoViewScaleState.initial);
|
||||
}
|
||||
_isZooming = value != PhotoViewScaleState.initial;
|
||||
// _logger.info('is reakky zooming $_isZooming with state $value');
|
||||
};
|
||||
super.initState();
|
||||
}
|
||||
|
@ -62,9 +65,10 @@ class _ZoomableImageState extends State<ZoomableImage>
|
|||
} else {
|
||||
_loadLocalImage(context);
|
||||
}
|
||||
Widget content;
|
||||
|
||||
if (_imageProvider != null) {
|
||||
return PhotoView(
|
||||
content = PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
scaleStateChangedCallback: _scaleStateChangedCallback,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
|
@ -75,8 +79,19 @@ class _ZoomableImageState extends State<ZoomableImage>
|
|||
backgroundDecoration: widget.backgroundDecoration,
|
||||
);
|
||||
} 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() {
|
||||
|
|
|
@ -107,3 +107,128 @@ Future<T> showConfettiDialog<T>({
|
|||
routeSettings: routeSettings,
|
||||
);
|
||||
}
|
||||
|
||||
Widget test() {
|
||||
return Container(
|
||||
width: 355,
|
||||
height: 236,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 355,
|
||||
height: 236,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Color(0x4c000000),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 20,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 234,
|
||||
child: Text(
|
||||
"Are you sure you want to logout?",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontFamily: "SF Pro Display",
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Container(
|
||||
width: 323,
|
||||
height: 48,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 323,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Color(0x4c000000),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 135,
|
||||
right: 136,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Cancel",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Container(
|
||||
width: 323,
|
||||
height: 48,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 323,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.white,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 120,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Yes Logout",
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontFamily: "SF Pro Text",
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,8 +4,10 @@ import 'package:logging/logging.dart';
|
|||
import 'package:path/path.dart';
|
||||
import 'package:photos/core/event_bus.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/magic_metadata.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/file_magic_service.dart';
|
||||
import 'package:photos/ui/rename_dialog.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(
|
||||
BuildContext context, List<File> files, int editedTime) async {
|
||||
try {
|
||||
|
|
21
pubspec.lock
21
pubspec.lock
|
@ -232,6 +232,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
dotted_border:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dotted_border
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0+2"
|
||||
email_validator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -786,6 +793,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.8.0"
|
||||
path_drawing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_drawing
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -11,7 +11,7 @@ description: ente photos application
|
|||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 0.5.10+290
|
||||
version: 0.5.11+291
|
||||
|
||||
environment:
|
||||
sdk: ">=2.10.0 <3.0.0"
|
||||
|
@ -33,6 +33,7 @@ dependencies:
|
|||
device_info: ^2.0.2
|
||||
dio: ^4.0.0
|
||||
dots_indicator: ^2.0.0
|
||||
dotted_border: ^2.0.0+2
|
||||
email_validator: ^2.0.1
|
||||
event_bus: ^2.0.0
|
||||
exif: ^3.0.0
|
||||
|
|
1
thirdparty/extended_image
vendored
Submodule
1
thirdparty/extended_image
vendored
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 112cc8be0c2f07129edd7575003a3f9ddb62ae3f
|
Loading…
Add table
Reference in a new issue