diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 000000000..2620f6410 --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,33 @@ +name: Check Linter Rules +on: + pull_request: + branches: + - master +jobs: + test: + if: github.event.pull_request.draft == 'false' + name: Check the source code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/cache@v2 + with: + path: ${{ runner.tool_cache }}/flutter + key: flutter-3.0.0-stable + # Setup the flutter environment. + - uses: subosito/flutter-action@v2.3.0 + with: + channel: 'stable' + flutter-version: '3.0.0' + + # Fetch sub modules + - run: git submodule update --init --recursive + + # Get flutter dependencies. + - name: Install packages + run: flutter pub get + + - name: Run Linter + run: flutter analyze --no-fatal-infos +# - name: Run Test :sed: +# run: flutter test diff --git a/analysis_options.yaml b/analysis_options.yaml index 0537fc253..495c8fd15 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -55,9 +55,11 @@ analyzer: prefer_const_constructors: warning prefer_const_declarations: warning prefer_const_constructors_in_immutables: warning + prefer_final_locals: warning unnecessary_const: error cancel_subscriptions: error + invalid_dependency: info use_build_context_synchronously: ignore # experimental lint, requires many changes prefer_interpolation_to_compose_strings: ignore # later too many warnings prefer_double_quotes: ignore # too many warnings diff --git a/android/app/build.gradle b/android/app/build.gradle index 48c96b9f0..ade8194a4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 32 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -47,7 +47,7 @@ android { defaultConfig { applicationId "io.ente.photos" minSdkVersion 19 - targetSdkVersion 30 + targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 819e1e246..bbab63d0b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,65 +1,91 @@ - - + + - + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + - - + + - - - - - + + + + + + + \ No newline at end of file diff --git a/assets/2.0x/storage_card_background.png b/assets/2.0x/storage_card_background.png index 26be3d8fc..22ecf7e12 100644 Binary files a/assets/2.0x/storage_card_background.png and b/assets/2.0x/storage_card_background.png differ diff --git a/assets/3.0x/storage_card_background.png b/assets/3.0x/storage_card_background.png index 8dfb56cd9..c74f0d728 100644 Binary files a/assets/3.0x/storage_card_background.png and b/assets/3.0x/storage_card_background.png differ diff --git a/assets/storage_card_background.png b/assets/storage_card_background.png index 724b8beb1..b17627f01 100644 Binary files a/assets/storage_card_background.png and b/assets/storage_card_background.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index aea725a36..735f8cfa6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -321,7 +321,7 @@ SPEC CHECKSUMS: FirebaseInstallations: 0a115432c4e223c5ab20b0dbbe4cbefa793a0e8e FirebaseMessaging: 732623518591384f61c287e3d8f65294beb7ffb3 fk_user_agent: 1f47ec39291e8372b1d692b50084b0d54103c545 - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b flutter_image_compress: 5a5e9aee05b6553048b8df1c3bc456d0afaac433 flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 diff --git a/lib/core/constants.dart b/lib/core/constants.dart index bde45d4b6..f9cd64604 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -11,6 +11,7 @@ const String sentryTunnel = "https://sentry-reporter.ente.io"; const String roadmapURL = "https://roadmap.ente.io"; const int microSecondsInDay = 86400000000; const int android11SDKINT = 30; +const int jan011991Time = 31580904000000; const int galleryLoadStartTime = -8000000000000000; // Wednesday, March 6, 1748 const int galleryLoadEndTime = 9223372036854775807; // 2^63 -1 diff --git a/lib/core/network.dart b/lib/core/network.dart index eb83f8175..0d396e5d7 100644 --- a/lib/core/network.dart +++ b/lib/core/network.dart @@ -66,8 +66,10 @@ class EnteRequestInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (kDebugMode) { - assert(options.baseUrl == Network.apiEndpoint, - "interceptor should only be used for API endpoint"); + assert( + options.baseUrl == Network.apiEndpoint, + "interceptor should only be used for API endpoint", + ); } // ignore: prefer_const_constructors options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); diff --git a/lib/db/device_files_db.dart b/lib/db/device_files_db.dart index 43ee808c6..4f8b01882 100644 --- a/lib/db/device_files_db.dart +++ b/lib/db/device_files_db.dart @@ -372,8 +372,10 @@ extension DeviceFiles on FilesDB { deviceCollections.add(deviceCollection); } if (includeCoverThumbnail) { - deviceCollections.sort((a, b) => - b.thumbnail.creationTime.compareTo(a.thumbnail.creationTime)); + deviceCollections.sort( + (a, b) => + b.thumbnail.creationTime.compareTo(a.thumbnail.creationTime), + ); } return deviceCollections; } catch (e) { diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index b58d98349..5c39a2cc7 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -12,7 +12,6 @@ import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/location.dart'; import 'package:photos/models/magic_metadata.dart'; -import 'package:photos/services/feature_flag_service.dart'; import 'package:photos/utils/file_uploader_util.dart'; import 'package:sqflite/sqflite.dart'; import 'package:sqflite_migration/sqflite_migration.dart'; @@ -611,17 +610,9 @@ class FilesDB { }) async { final db = await instance.database; final order = (asc ?? false ? 'ASC' : 'DESC'); - String whereClause; - List whereArgs; - if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) { - whereClause = - '$columnCollectionID = ? AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnMMdVisibility = ?'; - whereArgs = [collectionID, startTime, endTime, visibility]; - } else { - whereClause = - '$columnCollectionID = ? AND $columnCreationTime >= ? AND $columnCreationTime <= ?'; - whereArgs = [collectionID, startTime, endTime]; - } + const String whereClause = + '$columnCollectionID = ? AND $columnCreationTime >= ? AND $columnCreationTime <= ?'; + final List whereArgs = [collectionID, startTime, endTime]; final results = await db.query( filesTable, @@ -636,6 +627,43 @@ class FilesDB { return FileLoadResult(files, files.length == limit); } + Future getFilesInCollections( + List collectionIDs, + int startTime, + int endTime, + int userID, { + int limit, + bool asc, + }) async { + if (collectionIDs.isEmpty) { + return FileLoadResult([], false); + } + String inParam = ""; + for (final id in collectionIDs) { + inParam += "'" + id.toString() + "',"; + } + inParam = inParam.substring(0, inParam.length - 1); + final db = await instance.database; + final order = (asc ?? false ? 'ASC' : 'DESC'); + final String whereClause = + '$columnCollectionID IN ($inParam) AND $columnCreationTime >= ? AND ' + '$columnCreationTime <= ? AND $columnOwnerID = ?'; + final List whereArgs = [startTime, endTime, userID]; + + final results = await db.query( + filesTable, + where: whereClause, + whereArgs: whereArgs, + orderBy: + '$columnCreationTime ' + order + ', $columnModificationTime ' + order, + limit: limit, + ); + final files = convertToFiles(results); + final dedupeResult = _deduplicatedAndFilterIgnoredFiles(files, {}); + _logger.info("Fetched " + dedupeResult.length.toString() + " files"); + return FileLoadResult(files, files.length == limit); + } + Future> getFilesCreatedWithinDurations( List> durations, Set ignoredCollectionIDs, { @@ -1080,7 +1108,9 @@ class FilesDB { final db = await instance.database; final count = Sqflite.firstIntValue( await db.rawQuery( - 'SELECT COUNT(*) FROM $filesTable where $columnMMdVisibility = $visibility AND $columnOwnerID = $ownerID', + 'SELECT COUNT(distinct($columnUploadedFileID)) FROM $filesTable where ' + '$columnMMdVisibility' + ' = $visibility AND $columnOwnerID = $ownerID', ), ); return count; @@ -1143,25 +1173,7 @@ class FilesDB { Future> getLatestCollectionFiles() async { debugPrint("Fetching latestCollectionFiles from db"); - String query; - if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) { - query = ''' - SELECT $filesTable.* - FROM $filesTable - INNER JOIN - ( - SELECT $columnCollectionID, MAX($columnCreationTime) AS max_creation_time - FROM $filesTable - WHERE ($columnCollectionID IS NOT NULL AND $columnCollectionID IS - NOT -1 AND $columnMMdVisibility = $visibilityVisible AND - $columnUploadedFileID IS NOT -1) - GROUP BY $columnCollectionID - ) latest_files - ON $filesTable.$columnCollectionID = latest_files.$columnCollectionID - AND $filesTable.$columnCreationTime = latest_files.max_creation_time; - '''; - } else { - query = ''' + const String query = ''' SELECT $filesTable.* FROM $filesTable INNER JOIN @@ -1173,9 +1185,7 @@ class FilesDB { ) latest_files ON $filesTable.$columnCollectionID = latest_files.$columnCollectionID AND $filesTable.$columnCreationTime = latest_files.max_creation_time; - '''; - } final db = await instance.database; final rows = await db.rawQuery( query, @@ -1250,6 +1260,33 @@ class FilesDB { return result; } + Future>> getAllFilesGroupByCollectionID( + List ids, + ) async { + final result = >{}; + if (ids.isEmpty) { + return result; + } + String inParam = ""; + for (final id in ids) { + inParam += "'" + id.toString() + "',"; + } + inParam = inParam.substring(0, inParam.length - 1); + final db = await instance.database; + final results = await db.query( + filesTable, + where: '$columnUploadedFileID IN ($inParam)', + ); + final files = convertToFiles(results); + for (File eachFile in files) { + if (!result.containsKey(eachFile.collectionID)) { + result[eachFile.collectionID] = []; + } + result[eachFile.collectionID].add(eachFile); + } + return result; + } + Future> getAllCollectionIDsOfFile( int uploadedFileID, ) async { @@ -1276,15 +1313,28 @@ class FilesDB { return files; } - Future> getAllFilesFromDB() async { + Future> getAllFilesFromDB(Set collectionsToIgnore) async { final db = await instance.database; final List> result = await db.query(filesTable); final List files = convertToFiles(result); final List deduplicatedFiles = - _deduplicatedAndFilterIgnoredFiles(files, null); + _deduplicatedAndFilterIgnoredFiles(files, collectionsToIgnore); return deduplicatedFiles; } + Future> fetchFilesCountbyType(int userID) async { + final db = await instance.database; + final result = await db.rawQuery( + "SELECT $columnFileType, COUNT(DISTINCT $columnUploadedFileID) FROM $filesTable WHERE $columnUploadedFileID != -1 AND $columnOwnerID == $userID GROUP BY $columnFileType", + ); + + final filesCount = {}; + for (var e in result) { + filesCount.addAll({getFileType(e[columnFileType]): e.values.last}); + } + return filesCount; + } + Map _getRowForFile(File file) { final row = {}; if (file.generatedID != null) { diff --git a/lib/events/files_updated_event.dart b/lib/events/files_updated_event.dart index 133a2898a..4de73ee74 100644 --- a/lib/events/files_updated_event.dart +++ b/lib/events/files_updated_event.dart @@ -20,4 +20,6 @@ enum EventType { deletedFromEverywhere, archived, unarchived, + hide, + unhide, } diff --git a/lib/events/tab_changed_event.dart b/lib/events/tab_changed_event.dart index 9ecd71811..6a233bba3 100644 --- a/lib/events/tab_changed_event.dart +++ b/lib/events/tab_changed_event.dart @@ -16,3 +16,9 @@ enum TabChangedEventSource { collectionsPage, backButton, } + +class TabDoubleTapEvent extends Event { + final int selectedIndex; + + TabDoubleTapEvent(this.selectedIndex); +} diff --git a/lib/models/api/collection/create_request.dart b/lib/models/api/collection/create_request.dart new file mode 100644 index 000000000..54bba08c3 --- /dev/null +++ b/lib/models/api/collection/create_request.dart @@ -0,0 +1,57 @@ +import 'package:photos/models/collection.dart'; +import 'package:photos/services/file_magic_service.dart'; + +class CreateRequest { + String encryptedKey; + String keyDecryptionNonce; + String encryptedName; + String nameDecryptionNonce; + String type; + CollectionAttributes? attributes; + MetadataRequest? magicMetadata; + + CreateRequest({ + required this.encryptedKey, + required this.keyDecryptionNonce, + required this.encryptedName, + required this.nameDecryptionNonce, + required this.type, + this.attributes, + this.magicMetadata, + }); + + CreateRequest copyWith({ + String? encryptedKey, + String? keyDecryptionNonce, + String? encryptedName, + String? nameDecryptionNonce, + String? type, + CollectionAttributes? attributes, + MetadataRequest? magicMetadata, + }) => + CreateRequest( + encryptedKey: encryptedKey ?? this.encryptedKey, + keyDecryptionNonce: keyDecryptionNonce ?? this.keyDecryptionNonce, + encryptedName: encryptedName ?? this.encryptedName, + nameDecryptionNonce: nameDecryptionNonce ?? this.nameDecryptionNonce, + type: type ?? this.type, + attributes: attributes ?? this.attributes, + magicMetadata: magicMetadata ?? this.magicMetadata, + ); + + Map toJson() { + final map = {}; + map['encryptedKey'] = encryptedKey; + map['keyDecryptionNonce'] = keyDecryptionNonce; + map['encryptedName'] = encryptedName; + map['nameDecryptionNonce'] = nameDecryptionNonce; + map['type'] = type; + if (attributes != null) { + map['attributes'] = attributes!.toMap(); + } + if (magicMetadata != null) { + map['magicMetadata'] = magicMetadata!.toJson(); + } + return map; + } +} diff --git a/lib/models/collection.dart b/lib/models/collection.dart index 5f220aeb7..48f63f681 100644 --- a/lib/models/collection.dart +++ b/lib/models/collection.dart @@ -46,6 +46,17 @@ class Collection { return mMdVersion > 0 && magicMetadata.visibility == visibilityArchive; } + bool isHidden() { + if (isDefaultHidden()) { + return true; + } + return mMdVersion > 0 && (magicMetadata.visibility == visibilityHidden); + } + + bool isDefaultHidden() { + return (magicMetadata.subType ?? 0) == subTypeDefaultHidden; + } + static CollectionType typeFromString(String type) { switch (type) { case "folder": diff --git a/lib/models/file.dart b/lib/models/file.dart index 230cb3171..a620ed090 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -9,6 +9,7 @@ import 'package:photos/models/location.dart'; import 'package:photos/models/magic_metadata.dart'; // ignore: import_of_legacy_library_into_null_safe import 'package:photos/services/feature_flag_service.dart'; +import 'package:photos/utils/date_time_util.dart'; // ignore: import_of_legacy_library_into_null_safe import 'package:photos/utils/exif_util.dart'; // ignore: import_of_legacy_library_into_null_safe @@ -74,16 +75,13 @@ class File extends EnteFile { file.location = Location(asset.latitude, asset.longitude); file.fileType = _fileTypeFromAsset(asset); file.creationTime = asset.createDateTime.microsecondsSinceEpoch; - if (file.creationTime == 0) { + if (file.creationTime == null || (file.creationTime! <= jan011991Time)) { try { - final parsedDateTime = DateTime.parse( - basenameWithoutExtension(file.title!) - .replaceAll("IMG_", "") - .replaceAll("VID_", "") - .replaceAll("DCIM_", "") - .replaceAll("_", " "), - ); - file.creationTime = parsedDateTime.microsecondsSinceEpoch; + final parsedDateTime = + parseDateFromFileName(basenameWithoutExtension(file.title ?? "")); + + file.creationTime = parsedDateTime?.microsecondsSinceEpoch ?? + asset.modifiedDateTime.microsecondsSinceEpoch; } catch (e) { file.creationTime = asset.modifiedDateTime.microsecondsSinceEpoch; } @@ -101,9 +99,7 @@ class File extends EnteFile { type = FileType.image; // PHAssetMediaSubtype.photoLive.rawValue is 8 // This hack should go away once photos_manager support livePhotos - if (asset.subtype != null && - asset.subtype > -1 && - (asset.subtype & 8) != 0) { + if (asset.subtype > -1 && (asset.subtype & 8) != 0) { type = FileType.livePhoto; } break; @@ -165,9 +161,9 @@ class File extends EnteFile { duration = asset.duration; } } - if (fileType == FileType.image) { + if (fileType == FileType.image && mediaUploadData.sourceFile != null) { final exifTime = - await getCreationTimeFromEXIF(mediaUploadData.sourceFile); + await getCreationTimeFromEXIF(mediaUploadData.sourceFile!); if (exifTime != null) { creationTime = exifTime.microsecondsSinceEpoch; } @@ -215,6 +211,10 @@ class File extends EnteFile { } } + String? get caption { + return pubMagicMetadata?.caption; + } + String get thumbnailUrl { final endpoint = Configuration.instance.getHttpEndpoint(); if (endpoint != kDefaultProductionEndpoint || diff --git a/lib/models/gallery_type.dart b/lib/models/gallery_type.dart index 1b90656c8..93f17c974 100644 --- a/lib/models/gallery_type.dart +++ b/lib/models/gallery_type.dart @@ -1,6 +1,7 @@ enum GalleryType { homepage, archive, + hidden, trash, localFolder, // indicator for gallery view of collections shared with the user diff --git a/lib/models/magic_metadata.dart b/lib/models/magic_metadata.dart index 9edab547e..c55dff52f 100644 --- a/lib/models/magic_metadata.dart +++ b/lib/models/magic_metadata.dart @@ -1,12 +1,20 @@ import 'dart:convert'; +// Visibility Constants const visibilityVisible = 0; const visibilityArchive = 1; +const visibilityHidden = 2; + +// Collection SubType Constants +const subTypeDefaultHidden = 1; const magicKeyVisibility = 'visibility'; +// key for collection subType +const subTypeKey = 'subType'; const pubMagicKeyEditedTime = 'editedTime'; const pubMagicKeyEditedName = 'editedName'; +const pubMagicKeyCaption = "caption"; class MagicMetadata { // 0 -> visible @@ -32,8 +40,9 @@ class MagicMetadata { class PubMagicMetadata { int? editedTime; String? editedName; + String? caption; - PubMagicMetadata({this.editedTime, this.editedName}); + PubMagicMetadata({this.editedTime, this.editedName, this.caption}); factory PubMagicMetadata.fromEncodedJson(String encodedJson) => PubMagicMetadata.fromJson(jsonDecode(encodedJson)); @@ -46,6 +55,7 @@ class PubMagicMetadata { return PubMagicMetadata( editedTime: map[pubMagicKeyEditedTime], editedName: map[pubMagicKeyEditedName], + caption: map[pubMagicKeyCaption], ); } } @@ -56,7 +66,19 @@ class CollectionMagicMetadata { // 2 -> hidden etc? int visibility; - CollectionMagicMetadata({required this.visibility}); + // null/0 value -> no subType + // 1 -> DEFAULT_HIDDEN COLLECTION for files hidden individually + int? subType; + + CollectionMagicMetadata({required this.visibility, this.subType}); + + Map toJson() { + final result = {magicKeyVisibility: visibility}; + if (subType != null) { + result[subTypeKey] = subType!; + } + return result; + } factory CollectionMagicMetadata.fromEncodedJson(String encodedJson) => CollectionMagicMetadata.fromJson(jsonDecode(encodedJson)); @@ -68,6 +90,7 @@ class CollectionMagicMetadata { if (map == null) return null; return CollectionMagicMetadata( visibility: map[magicKeyVisibility] ?? visibilityVisible, + subType: map[subTypeKey], ); } } diff --git a/lib/models/search/search_result.dart b/lib/models/search/search_result.dart index c0fd85503..559ad7d24 100644 --- a/lib/models/search/search_result.dart +++ b/lib/models/search/search_result.dart @@ -22,5 +22,6 @@ enum ResultType { year, fileType, fileExtension, + fileCaption, event } diff --git a/lib/models/user_details.dart b/lib/models/user_details.dart index 0c1a039d0..e7d120df8 100644 --- a/lib/models/user_details.dart +++ b/lib/models/user_details.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +import 'package:photos/models/file_type.dart'; import 'package:photos/models/subscription.dart'; class UserDetails extends Equatable { @@ -118,3 +119,19 @@ class FamilyData { ); } } + +class FilesCount { + final Map filesCount; + FilesCount(this.filesCount); + + int get total => + images + videos + livePhotos + (filesCount[getInt(FileType.other)] ?? 0); + + int get photos => images + livePhotos; + + int get images => filesCount[FileType.image] ?? 0; + + int get videos => filesCount[FileType.video] ?? 0; + + int get livePhotos => filesCount[FileType.livePhoto] ?? 0; +} diff --git a/lib/services/collections_service.dart b/lib/services/collections_service.dart index 48fc9660e..86618e2c5 100644 --- a/lib/services/collections_service.dart +++ b/lib/services/collections_service.dart @@ -21,6 +21,7 @@ import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/force_reload_home_gallery_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; +import 'package:photos/models/api/collection/create_request.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_file_item.dart'; import 'package:photos/models/collection_items.dart'; @@ -51,6 +52,7 @@ class CollectionsService { final _localPathToCollectionID = {}; final _collectionIDToCollections = {}; final _cachedKeys = {}; + Collection cachedDefaultHiddenCollection; CollectionsService._privateConstructor() { _db = CollectionsDB.instance; @@ -78,6 +80,15 @@ class CollectionsService { }); } + Configuration get config => _config; + + Map get collectionIDToCollections => + _collectionIDToCollections; + + FilesDB get filesDB => _filesDB; + + // sync method fetches just sync the collections, not the individual files + // within the collection. Future> sync() async { _logger.info("Syncing collections"); final lastCollectionUpdationTime = @@ -145,6 +156,22 @@ class CollectionsService { .toSet(); } + Set getHiddenCollections() { + return _collectionIDToCollections.values + .toList() + .where((element) => element.isHidden()) + .map((e) => e.id) + .toSet(); + } + + Set collectionsHiddenFromTimeline() { + return _collectionIDToCollections.values + .toList() + .where((element) => element.isHidden() || element.isArchived()) + .map((e) => e.id) + .toSet(); + } + int getCollectionSyncTime(int collectionID) { return _prefs .getInt(_collectionSyncTimeKeyPrefix + collectionID.toString()) ?? @@ -177,6 +204,8 @@ class CollectionsService { }) async { final List collectionsWithThumbnail = []; final usersCollection = getActiveCollections(); + // remove any hidden collection to avoid accidental rendering on UI + usersCollection.removeWhere((element) => element.isHidden()); if (!includedOwnedByOthers) { final userID = Configuration.instance.getUserID(); usersCollection.removeWhere((c) => c.owner.id != userID); @@ -298,6 +327,7 @@ class CollectionsService { } Uint8List _getDecryptedKey(Collection collection) { + debugPrint("Finding collection decryption key for ${collection.id}"); final encryptedKey = Sodium.base642bin(collection.encryptedKey); if (collection.owner.id == _config.getUserID()) { if (_config.getKey() == null) { @@ -820,17 +850,17 @@ class CollectionsService { List files, ) { if (toCollectionID == fromCollectionID) { - throw AssertionError("can't move to same album"); + throw AssertionError("Can't move to same album"); } for (final file in files) { if (file.uploadedFileID == null) { - throw AssertionError("can only move uploaded memories"); + throw AssertionError("Can only move uploaded memories"); } if (file.collectionID != fromCollectionID) { - throw AssertionError("all memories should belong to the same album"); + throw AssertionError("All memories should belong to the same album"); } if (file.ownerID != Configuration.instance.getUserID()) { - throw AssertionError("can only move memories uploaded by you"); + throw AssertionError("Can only move memories uploaded by you"); } } } @@ -854,11 +884,16 @@ class CollectionsService { RemoteSyncService.instance.sync(silently: true); } - Future createAndCacheCollection(Collection collection) async { + Future createAndCacheCollection( + Collection collection, { + CreateRequest createRequest, + }) async { + final dynamic payload = + createRequest != null ? createRequest.toJson() : collection.toMap(); return _enteDio .post( "/collections", - data: collection.toMap(), + data: payload, ) .then((response) { final collection = Collection.fromMap(response.data["collection"]); diff --git a/lib/services/feature_flag_service.dart b/lib/services/feature_flag_service.dart index 502c272e2..47517d183 100644 --- a/lib/services/feature_flag_service.dart +++ b/lib/services/feature_flag_service.dart @@ -73,10 +73,8 @@ class FeatureFlagService { .getDio() .get("https://static.ente.io/feature_flags.json"); final flagsResponse = FeatureFlags.fromMap(response.data); - if (flagsResponse != null) { - _prefs.setString(_featureFlagsKey, flagsResponse.toJson()); - _featureFlags = flagsResponse; - } + _prefs.setString(_featureFlagsKey, flagsResponse.toJson()); + _featureFlags = flagsResponse; } catch (e) { _logger.severe("Failed to sync feature flags ", e); } diff --git a/lib/services/hidden_service.dart b/lib/services/hidden_service.dart new file mode 100644 index 000000000..af9cc96a7 --- /dev/null +++ b/lib/services/hidden_service.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/events/force_reload_home_gallery_event.dart'; +import 'package:photos/events/local_photos_updated_event.dart'; +import 'package:photos/models/api/collection/create_request.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/utils/crypto_util.dart'; +import 'package:photos/utils/dialog_util.dart'; + +extension HiddenService on CollectionsService { + static final _logger = Logger("HiddenCollectionService"); + + // getDefaultHiddenCollection will return null if there's no default + // collection + Future getDefaultHiddenCollection() async { + if (cachedDefaultHiddenCollection != null) { + return cachedDefaultHiddenCollection; + } + final int userID = config.getUserID()!; + final Collection? defaultHidden = + collectionIDToCollections.values.firstWhereOrNull( + (element) => element.isDefaultHidden() && element.owner!.id == userID, + ); + if (defaultHidden != null) { + cachedDefaultHiddenCollection = defaultHidden; + return cachedDefaultHiddenCollection; + } + final Collection createdHiddenCollection = + await _createDefaultHiddenAlbum(); + cachedDefaultHiddenCollection = createdHiddenCollection; + return cachedDefaultHiddenCollection; + } + + Future hideFiles( + BuildContext context, + List filesToHide, { + bool forceHide = false, + }) async { + final int userID = config.getUserID()!; + final List uploadedIDs = []; + final dialog = createProgressDialog( + context, + "Hiding...", + ); + await dialog.show(); + try { + for (File file in filesToHide) { + if (file.uploadedFileID == null) { + throw AssertionError("Can only hide uploaded files"); + } + if (file.ownerID != userID) { + throw AssertionError("Can only hide files owned by user"); + } + uploadedIDs.add(file.uploadedFileID!); + } + + final defaultHiddenCollection = await getDefaultHiddenCollection(); + final Map> collectionToFilesMap = + await filesDB.getAllFilesGroupByCollectionID(uploadedIDs); + for (MapEntry> entry in collectionToFilesMap.entries) { + if (entry.key == defaultHiddenCollection.id) { + _logger.finest('file already part of hidden collection'); + continue; + } + await move(defaultHiddenCollection.id, entry.key, entry.value); + } + Bus.instance.fire(ForceReloadHomeGalleryEvent()); + Bus.instance.fire( + LocalPhotosUpdatedEvent(filesToHide, type: EventType.unarchived), + ); + + await dialog.hide(); + } on AssertionError catch (e) { + await dialog.hide(); + showErrorDialog(context, "Oops", e.message as String); + } catch (e, s) { + _logger.severe("Could not hide", e, s); + await dialog.hide(); + showGenericErrorDialog(context); + return false; + } finally { + await dialog.hide(); + } + return true; + } + + Future _createDefaultHiddenAlbum() async { + final key = CryptoUtil.generateKey(); + final encryptedKeyData = CryptoUtil.encryptSync(key, config.getKey()!); + final encryptedName = CryptoUtil.encryptSync( + utf8.encode(".Hidden") as Uint8List, + key, + ); + final jsonToUpdate = CollectionMagicMetadata( + visibility: visibilityHidden, + subType: subTypeDefaultHidden, + ).toJson(); + assert(jsonToUpdate.length == 2, "metadata should have two keys"); + final encryptedMMd = await CryptoUtil.encryptChaCha( + utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List, + key, + ); + final MetadataRequest metadataRequest = MetadataRequest( + version: 1, + count: jsonToUpdate.length, + data: Sodium.bin2base64(encryptedMMd.encryptedData!), + header: Sodium.bin2base64(encryptedMMd.header!), + ); + final CreateRequest createRequest = CreateRequest( + encryptedKey: Sodium.bin2base64(encryptedKeyData.encryptedData!), + keyDecryptionNonce: Sodium.bin2base64(encryptedKeyData.nonce!), + encryptedName: Sodium.bin2base64(encryptedName.encryptedData!), + nameDecryptionNonce: Sodium.bin2base64(encryptedName.nonce!), + type: CollectionType.album.toString(), + attributes: CollectionAttributes(), + magicMetadata: metadataRequest, + ); + + _logger.info("Creating Hidden Collection"); + final collection = + await createAndCacheCollection(null, createRequest: createRequest); + _logger.info("Creating Hidden Collection Created Successfully"); + final Collection collectionFromServer = + await fetchCollectionByID(collection.id); + _logger.info("Fetched Created Hidden Collection Successfully"); + return collectionFromServer; + } +} diff --git a/lib/services/local/local_sync_util.dart b/lib/services/local/local_sync_util.dart index 76c5997a4..b3f8e6ac6 100644 --- a/lib/services/local/local_sync_util.dart +++ b/lib/services/local/local_sync_util.dart @@ -264,8 +264,10 @@ Future> _getAllAssetLists(AssetPathEntity pathEntity) async { size: assetFetchPageSize, ); Bus.instance.fire( - LocalImportProgressEvent(pathEntity.name, - currentPage * assetFetchPageSize + currentPageResult.length), + LocalImportProgressEvent( + pathEntity.name, + currentPage * assetFetchPageSize + currentPageResult.length, + ), ); result.addAll(currentPageResult); currentPage = currentPage + 1; diff --git a/lib/services/memories_service.dart b/lib/services/memories_service.dart index 8e07974c5..4253ad0bb 100644 --- a/lib/services/memories_service.dart +++ b/lib/services/memories_service.dart @@ -74,11 +74,11 @@ class MemoriesService extends ChangeNotifier { date.add(const Duration(days: daysAfter)).microsecondsSinceEpoch; durations.add([startCreationTime, endCreationTime]); } - final archivedCollectionIds = - CollectionsService.instance.getArchivedCollections(); + final ignoredCollections = + CollectionsService.instance.collectionsHiddenFromTimeline(); final files = await _filesDB.getFilesCreatedWithinDurations( durations, - archivedCollectionIds, + ignoredCollections, ); final seenTimes = await _memoriesDB.getSeenTimes(); final List memories = []; diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index f4b0fcff1..8c0314800 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -129,6 +129,7 @@ class RemoteSyncService { // session are not processed now sync(); } else { + debugPrint("Fire backup completed event"); Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup)); } } else { @@ -259,7 +260,6 @@ class RemoteSyncService { await _db.getDevicePathIDToLocalIDMap(); bool moreFilesMarkedForBackup = false; for (final deviceCollection in deviceCollections) { - _logger.fine("processing ${deviceCollection.name}"); final Set localIDsToSync = pathIdToLocalIDs[deviceCollection.id] ?? {}; if (deviceCollection.uploadStrategy == UploadStrategy.ifMissing) { @@ -360,16 +360,20 @@ class RemoteSyncService { if (pendingUploads.isEmpty) { continue; } else { - _logger.info("RemovingFiles $collectionIDs: pendingUploads " - "${pendingUploads.length}"); + _logger.info( + "RemovingFiles $collectionIDs: pendingUploads " + "${pendingUploads.length}", + ); } final Set localIDsInOtherFileEntries = await _db.getLocalIDsPresentInEntries( pendingUploads, collectionID, ); - _logger.info("RemovingFiles $collectionIDs: filesInOtherCollection " - "${localIDsInOtherFileEntries.length}"); + _logger.info( + "RemovingFiles $collectionIDs: filesInOtherCollection " + "${localIDsInOtherFileEntries.length}", + ); final List entriesToUpdate = []; final List entriesToDelete = []; for (File pendingUpload in pendingUploads) { @@ -400,7 +404,7 @@ class RemoteSyncService { if (collectionByID == null || collectionByID.isDeleted) { _logger.info( "Collection $deviceCollectionID either deleted or missing " - "for path ${deviceCollection.name}", + "for path ${deviceCollection.id}", ); deviceCollectionID = -1; } diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 4ae2a2281..655080625 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -32,28 +32,23 @@ class SearchService { static final SearchService instance = SearchService._privateConstructor(); Future init() async { - // Intention of delay is to give more CPU cycles to other tasks - // 8 is just a magic number - Future.delayed(const Duration(seconds: 8), () async { - /* In case home screen loads before 8 seconds and user starts search, - future will not be null.So here getAllFiles won't run again in that case. */ - if (_cachedFilesFuture == null) { - _getAllFiles(); - } - }); - Bus.instance.on().listen((event) { // only invalidate, let the load happen on demand _cachedFilesFuture = null; }); } + Set ignoreCollections() { + return CollectionsService.instance.getHiddenCollections(); + } + Future> _getAllFiles() async { if (_cachedFilesFuture != null) { return _cachedFilesFuture; } _logger.fine("Reading all files from db"); - _cachedFilesFuture = FilesDB.instance.getAllFilesFromDB(); + _cachedFilesFuture = + FilesDB.instance.getAllFilesFromDB(ignoreCollections()); return _cachedFilesFuture; } @@ -133,7 +128,11 @@ class SearchService { if (collectionSearchResults.length >= _maximumResultsLimit) { break; } - if (c.collection.name.toLowerCase().contains(query.toLowerCase())) { + + if (!c.collection.isHidden() && + c.collection.name.toLowerCase().contains( + query.toLowerCase(), + )) { collectionSearchResults.add(AlbumSearchResult(c)); } } @@ -172,7 +171,7 @@ class SearchService { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsForCalendarDateInEveryYear(holiday.day, holiday.month), - null, + ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { @@ -209,6 +208,30 @@ class SearchService { return searchResults; } + Future> getCaptionResults( + String query, + ) async { + final List searchResults = []; + if (query.isEmpty) { + return searchResults; + } + final RegExp pattern = RegExp(query, caseSensitive: false); + final List allFiles = await _getAllFiles(); + final matchedFiles = allFiles + .where((e) => e.caption != null && pattern.hasMatch(e.caption)) + .toList(); + if (matchedFiles.isNotEmpty) { + searchResults.add( + GenericSearchResult( + ResultType.fileCaption, + query, + matchedFiles, + ), + ); + } + return searchResults; + } + Future> getFileExtensionResults( String query, ) async { @@ -248,7 +271,7 @@ class SearchService { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsOfMonthInEveryYear(month.monthNumber), - null, + ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { @@ -277,7 +300,7 @@ class SearchService { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsForCalendarDateInEveryYear(day, month, year: year), - null, + ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { @@ -305,7 +328,7 @@ class SearchService { Future> _getFilesInYear(List durationOfYear) async { return await FilesDB.instance.getFilesCreatedWithinDurations( [durationOfYear], - null, + ignoreCollections(), order: "DESC", ); } diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index 5e39f7b3f..976d34d88 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -11,11 +11,13 @@ class EnteColorScheme { // Backdrop Colors final Color backdropBase; final Color backdropBaseMute; + final Color backdropFaint; // Text Colors final Color textBase; final Color textMuted; final Color textFaint; + final Color blurTextBase; // Fill Colors final Color fillBase; @@ -27,6 +29,9 @@ class EnteColorScheme { final Color strokeMuted; final Color strokeFaint; final Color strokeFainter; + final Color blurStrokeBase; + final Color blurStrokeFaint; + final Color blurStrokePressed; // Fixed Colors final Color primary700; @@ -49,9 +54,11 @@ class EnteColorScheme { this.backgroundElevated2, this.backdropBase, this.backdropBaseMute, + this.backdropFaint, this.textBase, this.textMuted, this.textFaint, + this.blurTextBase, this.fillBase, this.fillMuted, this.fillFaint, @@ -59,6 +66,9 @@ class EnteColorScheme { this.strokeMuted, this.strokeFaint, this.strokeFainter, + this.blurStrokeBase, + this.blurStrokeFaint, + this.blurStrokePressed, this.tabIcon, { this.primary700 = _primary700, this.primary500 = _primary500, @@ -76,10 +86,12 @@ const EnteColorScheme lightScheme = EnteColorScheme( backgroundElevatedLight, backgroundElevated2Light, backdropBaseLight, - backdropBaseMuteLight, + backdropMutedLight, + backdropFaintLight, textBaseLight, textMutedLight, textFaintLight, + blurTextBaseLight, fillBaseLight, fillMutedLight, fillFaintLight, @@ -87,6 +99,9 @@ const EnteColorScheme lightScheme = EnteColorScheme( strokeMutedLight, strokeFaintLight, strokeFainterLight, + blurStrokeBaseLight, + blurStrokeFaintLight, + blurStrokePressedLight, tabIconLight, ); @@ -95,10 +110,12 @@ const EnteColorScheme darkScheme = EnteColorScheme( backgroundElevatedDark, backgroundElevated2Dark, backdropBaseDark, - backdropBaseMuteDark, + backdropMutedDark, + backdropFaintDark, textBaseDark, textMutedDark, textFaintDark, + blurTextBaseDark, fillBaseDark, fillMutedDark, fillFaintDark, @@ -106,6 +123,9 @@ const EnteColorScheme darkScheme = EnteColorScheme( strokeMutedDark, strokeFaintDark, strokeFainterDark, + blurStrokeBaseDark, + blurStrokeFaintDark, + blurStrokePressedDark, tabIconDark, ); @@ -120,19 +140,23 @@ const Color backgroundElevated2Dark = Color.fromRGBO(37, 37, 37, 1); // Backdrop Colors const Color backdropBaseLight = Color.fromRGBO(255, 255, 255, 0.75); -const Color backdropBaseMuteLight = Color.fromRGBO(255, 255, 255, 0.30); +const Color backdropMutedLight = Color.fromRGBO(255, 255, 255, 0.30); +const Color backdropFaintLight = Color.fromRGBO(255, 255, 255, 0.15); const Color backdropBaseDark = Color.fromRGBO(0, 0, 0, 0.65); -const Color backdropBaseMuteDark = Color.fromRGBO(0, 0, 0, 0.20); +const Color backdropMutedDark = Color.fromRGBO(0, 0, 0, 0.20); +const Color backdropFaintDark = Color.fromRGBO(0, 0, 0, 0.08); // Text Colors const Color textBaseLight = Color.fromRGBO(0, 0, 0, 1); const Color textMutedLight = Color.fromRGBO(0, 0, 0, 0.6); const Color textFaintLight = Color.fromRGBO(0, 0, 0, 0.5); +const Color blurTextBaseLight = Color.fromRGBO(0, 0, 0, 0.65); const Color textBaseDark = Color.fromRGBO(255, 255, 255, 1); const Color textMutedDark = Color.fromRGBO(255, 255, 255, 0.7); const Color textFaintDark = Color.fromRGBO(255, 255, 255, 0.5); +const Color blurTextBaseDark = Color.fromRGBO(255, 255, 255, 0.95); // Fill Colors const Color fillBaseLight = Color.fromRGBO(0, 0, 0, 1); @@ -148,11 +172,17 @@ const Color strokeBaseLight = Color.fromRGBO(0, 0, 0, 1); const Color strokeMutedLight = Color.fromRGBO(0, 0, 0, 0.24); const Color strokeFaintLight = Color.fromRGBO(0, 0, 0, 0.12); const Color strokeFainterLight = Color.fromRGBO(0, 0, 0, 0.06); +const Color blurStrokeBaseLight = Color.fromRGBO(0, 0, 0, 0.65); +const Color blurStrokeFaintLight = Color.fromRGBO(0, 0, 0, 0.08); +const Color blurStrokePressedLight = Color.fromRGBO(0, 0, 0, 0.50); const Color strokeBaseDark = Color.fromRGBO(255, 255, 255, 1); const Color strokeMutedDark = Color.fromRGBO(255, 255, 255, 0.24); const Color strokeFaintDark = Color.fromRGBO(255, 255, 255, 0.16); const Color strokeFainterDark = Color.fromRGBO(255, 255, 255, 0.08); +const Color blurStrokeBaseDark = Color.fromRGBO(0, 0, 0, 0.90); +const Color blurStrokeFaintDark = Color.fromRGBO(0, 0, 0, 0.08); +const Color blurStrokePressedDark = Color.fromRGBO(0, 0, 0, 0.50); // Other colors const Color tabIconLight = Color.fromRGBO(0, 0, 0, 0.85); diff --git a/lib/theme/text_style.dart b/lib/theme/text_style.dart index 8b36c73ed..234ec3b93 100644 --- a/lib/theme/text_style.dart +++ b/lib/theme/text_style.dart @@ -5,6 +5,18 @@ const FontWeight _regularWeight = FontWeight.w500; const FontWeight _boldWeight = FontWeight.w600; const String _fontFamily = 'Inter'; +const TextStyle brandStyleSmall = TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Montserrat', + fontSize: 21, +); + +const TextStyle brandStyleMedium = TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Montserrat', + fontSize: 24, +); + const TextStyle h1 = TextStyle( fontSize: 48, height: 48 / 28, @@ -31,7 +43,7 @@ const TextStyle large = TextStyle( ); const TextStyle body = TextStyle( fontSize: 16, - height: 19.4 / 16.0, + height: 20 / 16.0, fontWeight: _regularWeight, fontFamily: _fontFamily, ); @@ -71,6 +83,8 @@ class EnteTextTheme { final TextStyle miniBold; final TextStyle tiny; final TextStyle tinyBold; + final TextStyle brandSmall; + final TextStyle brandMedium; const EnteTextTheme({ required this.h1, @@ -89,6 +103,8 @@ class EnteTextTheme { required this.miniBold, required this.tiny, required this.tinyBold, + required this.brandSmall, + required this.brandMedium, }); } @@ -113,5 +129,7 @@ EnteTextTheme _buildEnteTextStyle(Color color) { miniBold: mini.copyWith(color: color, fontWeight: _boldWeight), tiny: tiny.copyWith(color: color), tinyBold: tiny.copyWith(color: color, fontWeight: _boldWeight), + brandSmall: brandStyleSmall.copyWith(color: color), + brandMedium: brandStyleMedium.copyWith(color: color), ); } diff --git a/lib/ui/account/recovery_key_page.dart b/lib/ui/account/recovery_key_page.dart index e5b561a24..7990d078f 100644 --- a/lib/ui/account/recovery_key_page.dart +++ b/lib/ui/account/recovery_key_page.dart @@ -63,142 +63,148 @@ class _RecoveryKeyPageState extends State { : 120; return Scaffold( - appBar: widget.showProgressBar - ? AppBar( - elevation: 0, - title: Hero( - tag: "recovery_key", - child: StepProgressIndicator( - totalSteps: 4, - currentStep: 3, - selectedColor: - Theme.of(context).colorScheme.greenAlternative, - roundedEdges: const Radius.circular(10), - unselectedColor: Theme.of(context) - .colorScheme - .stepProgressUnselectedColor, - ), + appBar: widget.showProgressBar + ? AppBar( + elevation: 0, + title: Hero( + tag: "recovery_key", + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 3, + selectedColor: Theme.of(context).colorScheme.greenAlternative, + roundedEdges: const Radius.circular(10), + unselectedColor: + Theme.of(context).colorScheme.stepProgressUnselectedColor, ), - ) - : widget.showAppBar - ? AppBar( - elevation: 0, - title: Text(widget.title ?? "Recovery key"), - ) - : null, - body: Padding( - padding: EdgeInsets.fromLTRB(20, topPadding, 20, 20), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: constraints.maxWidth, - minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - widget.showAppBar - ? const SizedBox.shrink() - : Text( - widget.title ?? "Recovery key", - style: Theme.of(context).textTheme.headline4, - ), - Padding( - padding: - EdgeInsets.all(widget.showAppBar ? 0 : 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, - ), - const Padding(padding: EdgeInsets.only(top: 24)), - DottedBorder( - color: const Color.fromRGBO(17, 127, 56, 1), - //color of dotted/dash line - strokeWidth: 1, - //thickness of dash/dots - dashPattern: const [6, 6], - radius: const Radius.circular(8), - //dash patterns, 10 is dash width, 6 is space width - child: SizedBox( - //inner container - // height: 120, //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(context, - "Recovery key copied to clipboard"); - setState(() { - _hasTriedToSave = true; - }); - }, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromRGBO( - 49, 155, 86, .2), + ), + ) + : widget.showAppBar + ? AppBar( + elevation: 0, + title: Text(widget.title ?? "Recovery key"), + ) + : null, + body: Padding( + padding: EdgeInsets.fromLTRB(20, topPadding, 20, 20), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + widget.showAppBar + ? const SizedBox.shrink() + : Text( + widget.title ?? "Recovery key", + style: Theme.of(context).textTheme.headline4, + ), + Padding( + padding: EdgeInsets.all(widget.showAppBar ? 0 : 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, + ), + const Padding(padding: EdgeInsets.only(top: 24)), + DottedBorder( + color: const Color.fromRGBO(17, 127, 56, 1), + //color of dotted/dash line + strokeWidth: 1, + //thickness of dash/dots + dashPattern: const [6, 6], + radius: const Radius.circular(8), + //dash patterns, 10 is dash width, 6 is space width + child: SizedBox( + //inner container + // height: 120, //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( + context, + "Recovery key copied to clipboard", + ); + setState(() { + _hasTriedToSave = true; + }); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color.fromRGBO( + 49, + 155, + 86, + .2, ), - borderRadius: const BorderRadius.all( - Radius.circular(2), - ), - color: Theme.of(context) - .colorScheme - .recoveryKeyBoxColor, ), - padding: const EdgeInsets.all(20), - width: double.infinity, - child: Text( - recoveryKey, - style: - Theme.of(context).textTheme.bodyText1, + borderRadius: const BorderRadius.all( + Radius.circular(2), ), + color: Theme.of(context) + .colorScheme + .recoveryKeyBoxColor, + ), + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Text( + recoveryKey, + style: + Theme.of(context).textTheme.bodyText1, ), ), - ], - ), + ), + ], ), ), - SizedBox( - height: 80, + ), + SizedBox( + height: 80, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + widget.subText ?? + "We don’t store this key, please save this in a safe place.", + style: Theme.of(context).textTheme.bodyText1, + ), + ), + ), + Expanded( + child: Container( + alignment: Alignment.bottomCenter, width: double.infinity, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Text( - widget.subText ?? - "We don’t store this key, please save this in a safe place.", - style: Theme.of(context).textTheme.bodyText1, - ), + padding: const EdgeInsets.fromLTRB(10, 10, 10, 42), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _saveOptions(context, recoveryKey), ), ), - Expanded( - child: Container( - alignment: Alignment.bottomCenter, - width: double.infinity, - padding: const EdgeInsets.fromLTRB(10, 10, 10, 42), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _saveOptions(context, recoveryKey), - ), - ), - ) - ], - ), // columnEnds - ), + ) + ], + ), // columnEnds ), - ); - }, - ), - )); + ), + ); + }, + ), + ), + ); } List _saveOptions(BuildContext context, String recoveryKey) { diff --git a/lib/ui/account/verify_recovery_page.dart b/lib/ui/account/verify_recovery_page.dart index ebe059b50..e0b9aff29 100644 --- a/lib/ui/account/verify_recovery_page.dart +++ b/lib/ui/account/verify_recovery_page.dart @@ -148,16 +148,14 @@ class _VerifyRecoveryPageState extends State { SizedBox( width: double.infinity, child: Text( - 'Verify recovery key', + 'Confirm recovery key', style: enteTheme.textTheme.h3Bold, textAlign: TextAlign.left, ), ), const SizedBox(height: 18), Text( - "If you forget your password, your recovery key is the " - "only way to recover your photos.\n\nPlease verify that " - "you have safely backed up your 24 word recovery key by re-entering it.", + "Your recovery key is the only way to recover your photos if you forget your password. You can find your recovery key in Settings > Account.\n\nPlease enter your recovery key here to verify that you have saved it correctly.", style: enteTheme.textTheme.small .copyWith(color: enteTheme.colorScheme.textMuted), ), @@ -187,12 +185,6 @@ class _VerifyRecoveryPageState extends State { }, ), const SizedBox(height: 12), - Text( - "If you saved the recovery key from older app versions, you might have a 64 character recovery code instead of 24 words. You can enter that too.", - style: enteTheme.textTheme.mini - .copyWith(color: enteTheme.colorScheme.textMuted), - ), - const SizedBox(height: 8), Expanded( child: Container( alignment: Alignment.bottomCenter, @@ -204,8 +196,7 @@ class _VerifyRecoveryPageState extends State { children: [ GradientButton( onTap: _verifyRecoveryKey, - text: "Verify", - iconData: Icons.shield_outlined, + text: "Confirm", ), const SizedBox(height: 8), ], diff --git a/lib/ui/backup_settings_screen.dart b/lib/ui/backup_settings_screen.dart new file mode 100644 index 000000000..520a58455 --- /dev/null +++ b/lib/ui/backup_settings_screen.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; +import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/ui/components/toggle_switch_widget.dart'; + +class BackupSettingsScreen extends StatelessWidget { + const BackupSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: const TitleBarTitleWidget( + title: "Backup settings", + ), + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Backup over mobile data", + ), + menuItemColor: colorScheme.fillFaint, + trailingSwitch: ToggleSwitchWidget( + value: () { + return Configuration.instance + .shouldBackupOverMobileData(); + }, + onChanged: () async { + await Configuration.instance + .setBackupOverMobileData( + !Configuration.instance + .shouldBackupOverMobileData(), + ); + }, + ), + borderRadius: 8, + alignCaptionedTextToLeft: true, + isBottomBorderRadiusRemoved: true, + isGestureDetectorDisabled: true, + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: colorScheme.fillFaint, + ), + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Backup videos", + ), + menuItemColor: colorScheme.fillFaint, + trailingSwitch: ToggleSwitchWidget( + value: () => + Configuration.instance.shouldBackupVideos(), + onChanged: () => Configuration.instance + .setShouldBackupVideos( + !Configuration.instance.shouldBackupVideos(), + ), + ), + borderRadius: 8, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isGestureDetectorDisabled: true, + ), + ], + ), + const SizedBox(height: 24), + Platform.isIOS + ? Column( + children: [ + MenuItemWidget( + captionedTextWidget: + const CaptionedTextWidget( + title: "Disable auto lock", + ), + menuItemColor: colorScheme.fillFaint, + trailingSwitch: ToggleSwitchWidget( + value: () => Configuration.instance + .shouldKeepDeviceAwake(), + onChanged: () { + return Configuration.instance + .setShouldKeepDeviceAwake( + !Configuration.instance + .shouldKeepDeviceAwake(), + ); + }, + ), + borderRadius: 8, + alignCaptionedTextToLeft: true, + isGestureDetectorDisabled: true, + ), + const MenuSectionDescriptionWidget( + content: + "Disable the device screen lock when ente is in the foreground and there is a backup in progress. This is normally not needed, but may help big uploads and initial imports of large libraries complete faster.", + ) + ], + ) + : const SizedBox.shrink(), + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/collections/archived_collections_button_widget.dart b/lib/ui/collections/archived_collections_button_widget.dart new file mode 100644 index 000000000..95e0f5443 --- /dev/null +++ b/lib/ui/collections/archived_collections_button_widget.dart @@ -0,0 +1,104 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/db/files_db.dart'; +import 'package:photos/models/magic_metadata.dart'; +import 'package:photos/ui/viewer/gallery/archive_page.dart'; +import 'package:photos/utils/navigation_util.dart'; + +class ArchivedCollectionsButtonWidget extends StatelessWidget { + final TextStyle textStyle; + + const ArchivedCollectionsButtonWidget( + this.textStyle, { + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Theme.of(context).backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(0), + side: BorderSide( + width: 0.5, + color: Theme.of(context).iconTheme.color.withOpacity(0.24), + ), + ), + child: SizedBox( + height: 48, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.archive_outlined, + color: Theme.of(context).iconTheme.color, + ), + const Padding(padding: EdgeInsets.all(6)), + FutureBuilder( + future: FilesDB.instance.fileCountWithVisibility( + visibilityArchive, + Configuration.instance.getUserID(), + ), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data > 0) { + return RichText( + text: TextSpan( + style: textStyle, + children: [ + TextSpan( + text: "Archive", + style: Theme.of(context).textTheme.subtitle1, + ), + const TextSpan(text: " \u2022 "), + TextSpan( + text: snapshot.data.toString(), + ), + //need to query in db and bring this value + ], + ), + ); + } else { + return RichText( + text: TextSpan( + style: textStyle, + children: [ + TextSpan( + text: "Archive", + style: Theme.of(context).textTheme.subtitle1, + ), + //need to query in db and bring this value + ], + ), + ); + } + }, + ), + ], + ), + Icon( + Icons.chevron_right, + color: Theme.of(context).iconTheme.color, + ), + ], + ), + ), + ), + onPressed: () async { + routeToPage( + context, + ArchivePage(), + ); + }, + ); + } +} diff --git a/lib/ui/collections/collection_item_widget.dart b/lib/ui/collections/collection_item_widget.dart index 01497a2c1..e13076a0b 100644 --- a/lib/ui/collections/collection_item_widget.dart +++ b/lib/ui/collections/collection_item_widget.dart @@ -55,7 +55,7 @@ class CollectionItem extends StatelessWidget { FutureBuilder( future: FilesDB.instance.collectionFileCount(c.collection.id), builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! > 0) { + if (snapshot.hasData) { return Text( snapshot.data.toString(), style: enteTextTheme.small.copyWith( diff --git a/lib/ui/collections/ente_section_title.dart b/lib/ui/collections/ente_section_title.dart deleted file mode 100644 index e0dfa2551..000000000 --- a/lib/ui/collections/ente_section_title.dart +++ /dev/null @@ -1,49 +0,0 @@ -// @dart=2.9 - -import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; - -class EnteSectionTitle extends StatelessWidget { - final double opacity; - - const EnteSectionTitle({ - this.opacity = 0.8, - Key key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 12, 0, 0), - child: Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: "On ", - style: Theme.of(context) - .textTheme - .headline6 - .copyWith(fontSize: 22), - ), - TextSpan( - text: "ente", - style: TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'Montserrat', - fontSize: 22, - color: Theme.of(context).colorScheme.defaultTextColor, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/ui/collections/hidden_collections_button_widget.dart b/lib/ui/collections/hidden_collections_button_widget.dart index a236db176..284a77a65 100644 --- a/lib/ui/collections/hidden_collections_button_widget.dart +++ b/lib/ui/collections/hidden_collections_button_widget.dart @@ -1,10 +1,8 @@ // @dart=2.9 import 'package:flutter/material.dart'; -import 'package:photos/core/configuration.dart'; -import 'package:photos/db/files_db.dart'; -import 'package:photos/models/magic_metadata.dart'; -import 'package:photos/ui/viewer/gallery/archive_page.dart'; +import 'package:photos/services/local_authentication_service.dart'; +import 'package:photos/ui/viewer/gallery/hidden_page.dart'; import 'package:photos/utils/navigation_util.dart'; class HiddenCollectionsButtonWidget extends StatelessWidget { @@ -44,44 +42,25 @@ class HiddenCollectionsButtonWidget extends StatelessWidget { color: Theme.of(context).iconTheme.color, ), const Padding(padding: EdgeInsets.all(6)), - FutureBuilder( - future: FilesDB.instance.fileCountWithVisibility( - visibilityArchive, - Configuration.instance.getUserID(), + RichText( + text: TextSpan( + style: textStyle, + children: [ + TextSpan( + text: "Hidden", + style: Theme.of(context).textTheme.subtitle1, + ), + const TextSpan(text: " \u2022 "), + WidgetSpan( + child: Icon( + Icons.lock_outline, + size: 16, + color: Theme.of(context).iconTheme.color, + ), + ), + //need to query in db and bring this value + ], ), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data > 0) { - return RichText( - text: TextSpan( - style: textStyle, - children: [ - TextSpan( - text: "Hidden", - style: Theme.of(context).textTheme.subtitle1, - ), - const TextSpan(text: " \u2022 "), - TextSpan( - text: snapshot.data.toString(), - ), - //need to query in db and bring this value - ], - ), - ); - } else { - return RichText( - text: TextSpan( - style: textStyle, - children: [ - TextSpan( - text: "Hidden", - style: Theme.of(context).textTheme.subtitle1, - ), - //need to query in db and bring this value - ], - ), - ); - } - }, ), ], ), @@ -94,10 +73,17 @@ class HiddenCollectionsButtonWidget extends StatelessWidget { ), ), onPressed: () async { - routeToPage( + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( context, - ArchivePage(), + "Please authenticate to view your hidden files", ); + if (hasAuthenticated) { + routeToPage( + context, + HiddenPage(), + ); + } }, ); } diff --git a/lib/ui/collections/section_title.dart b/lib/ui/collections/section_title.dart index ea62ba365..7f72552d6 100644 --- a/lib/ui/collections/section_title.dart +++ b/lib/ui/collections/section_title.dart @@ -1,35 +1,61 @@ -// @dart=2.9 - import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/theme/text_style.dart'; class SectionTitle extends StatelessWidget { - final String title; - final Alignment alignment; - final double opacity; + final String? title; + final RichText? titleWithBrand; - const SectionTitle( - this.title, { - this.opacity = 0.8, - Key key, - this.alignment = Alignment.centerLeft, + const SectionTitle({ + this.title, + this.titleWithBrand, + Key? key, }) : super(key: key); @override Widget build(BuildContext context) { + final enteTextTheme = getEnteTextTheme(context); + Widget child; + if (titleWithBrand != null) { + child = titleWithBrand!; + } else if (title != null) { + child = Text( + title!, + style: enteTextTheme.largeBold, + ); + } else { + child = const SizedBox.shrink(); + } return Container( margin: const EdgeInsets.fromLTRB(16, 12, 0, 0), child: Column( children: [ Align( - alignment: alignment, - child: Text( - title, - style: - Theme.of(context).textTheme.headline6.copyWith(fontSize: 22), - ), + alignment: Alignment.centerLeft, + child: child, ), ], ), ); } } + +RichText getOnEnteSection(BuildContext context) { + final EnteTextTheme textTheme = getEnteTextTheme(context); + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: "On ", + style: TextStyle( + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + fontSize: 21, + color: textTheme.brandSmall.color, + ), + ), + TextSpan(text: "ente", style: textTheme.brandSmall), + ], + ), + ); +} diff --git a/lib/ui/collections_gallery_widget.dart b/lib/ui/collections_gallery_widget.dart index 0590bb22a..00544581b 100644 --- a/lib/ui/collections_gallery_widget.dart +++ b/lib/ui/collections_gallery_widget.dart @@ -14,8 +14,8 @@ import 'package:photos/events/user_logged_out_event.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_items.dart'; import 'package:photos/services/collections_service.dart'; +import 'package:photos/ui/collections/archived_collections_button_widget.dart'; import 'package:photos/ui/collections/device_folders_grid_view_widget.dart'; -import 'package:photos/ui/collections/ente_section_title.dart'; import 'package:photos/ui/collections/hidden_collections_button_widget.dart'; import 'package:photos/ui/collections/remote_collections_grid_view_widget.dart'; import 'package:photos/ui/collections/section_title.dart'; @@ -124,7 +124,7 @@ class _CollectionsGalleryWidgetState extends State child: Column( children: [ const SizedBox(height: 12), - const SectionTitle("On device"), + const SectionTitle(title: "On device"), const SizedBox(height: 12), const DeviceFoldersGridViewWidget(), const Padding(padding: EdgeInsets.all(4)), @@ -133,7 +133,7 @@ class _CollectionsGalleryWidgetState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ - const EnteSectionTitle(), + SectionTitle(titleWithBrand: getOnEnteSection(context)), _sortMenu(), ], ), @@ -148,9 +148,11 @@ class _CollectionsGalleryWidgetState extends State padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - TrashButtonWidget(trashAndHiddenTextStyle), + ArchivedCollectionsButtonWidget(trashAndHiddenTextStyle), const SizedBox(height: 12), HiddenCollectionsButtonWidget(trashAndHiddenTextStyle), + const SizedBox(height: 12), + TrashButtonWidget(trashAndHiddenTextStyle), ], ), ), diff --git a/lib/ui/common/loading_widget.dart b/lib/ui/common/loading_widget.dart index 8c84dae5e..7ddcd03a4 100644 --- a/lib/ui/common/loading_widget.dart +++ b/lib/ui/common/loading_widget.dart @@ -1,14 +1,23 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; class EnteLoadingWidget extends StatelessWidget { - const EnteLoadingWidget({Key? key}) : super(key: key); + final Color? color; + const EnteLoadingWidget({this.color, Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Center( - child: SizedBox.fromSize( - size: const Size.square(30), - child: const CupertinoActivityIndicator(), + child: Padding( + padding: const EdgeInsets.all(5), + child: SizedBox.fromSize( + size: const Size.square(14), + child: CircularProgressIndicator( + strokeWidth: 2, + color: color ?? getEnteColorScheme(context).strokeBase, + ), + ), ), ); } diff --git a/lib/ui/components/brand_title_widget.dart b/lib/ui/components/brand_title_widget.dart deleted file mode 100644 index 976adfc9d..000000000 --- a/lib/ui/components/brand_title_widget.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -enum SizeVarient { small, medium, large } - -extension ExtraSizeVarient on SizeVarient { - double size() { - if (this == SizeVarient.small) { - return 21; - } else if (this == SizeVarient.medium) { - return 24; - } else if (this == SizeVarient.large) { - return 28; - } - return -1; - } -} - -class BrandTitleWidget extends StatelessWidget { - final SizeVarient size; - - const BrandTitleWidget({required this.size, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Text( - "ente", - style: TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'Montserrat', - fontSize: size.size(), - ), - ); - } -} diff --git a/lib/ui/components/captioned_text_widget.dart b/lib/ui/components/captioned_text_widget.dart index 8934e9e9f..3de39bafb 100644 --- a/lib/ui/components/captioned_text_widget.dart +++ b/lib/ui/components/captioned_text_widget.dart @@ -23,7 +23,7 @@ class CaptionedTextWidget extends StatelessWidget { return Flexible( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 2), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 2), child: Row( children: [ Flexible( diff --git a/lib/ui/components/divider_widget.dart b/lib/ui/components/divider_widget.dart new file mode 100644 index 000000000..38c0643e3 --- /dev/null +++ b/lib/ui/components/divider_widget.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; + +enum DividerType { + solid, + menu, + menuNoIcon, + bottomBar, +} + +class DividerWidget extends StatelessWidget { + final DividerType dividerType; + final Color bgColor; + const DividerWidget({ + required this.dividerType, + this.bgColor = Colors.transparent, + super.key, + }); + + @override + Widget build(BuildContext context) { + final dividerColor = getEnteColorScheme(context).blurStrokeFaint; + if (dividerType == DividerType.solid) { + return Container( + color: getEnteColorScheme(context).strokeFaint, + width: double.infinity, + height: 1, + ); + } + if (dividerType == DividerType.bottomBar) { + return Container( + color: dividerColor, + width: double.infinity, + height: 1, + ); + } + + return Row( + children: [ + Container( + color: bgColor, + width: dividerType == DividerType.menu + ? 48 + : dividerType == DividerType.menuNoIcon + ? 16 + : 0, + height: 1, + ), + Expanded( + child: Container( + color: dividerColor, + height: 1, + width: double.infinity, + ), + ), + ], + ); + } +} diff --git a/lib/ui/components/expandable_menu_item_widget.dart b/lib/ui/components/expandable_menu_item_widget.dart index 472bae165..5a75fae33 100644 --- a/lib/ui/components/expandable_menu_item_widget.dart +++ b/lib/ui/components/expandable_menu_item_widget.dart @@ -44,32 +44,38 @@ class _ExpandableMenuItemWidgetState extends State { MediaQuery.of(context).platformBrightness == Brightness.light ? enteColorScheme.backgroundElevated2 : enteColorScheme.backgroundElevated; - return AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: expandableController.value ? backgroundColor : null, - borderRadius: BorderRadius.circular(4), - ), - child: ExpandableNotifier( - controller: expandableController, - child: ScrollOnExpand( - child: ExpandablePanel( - header: MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: widget.title, - makeTextBold: true, + return Padding( + padding: EdgeInsets.only(bottom: expandableController.value ? 8 : 0), + child: AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: expandableController.value ? backgroundColor : null, + borderRadius: BorderRadius.circular(4), + ), + child: ExpandableNotifier( + controller: expandableController, + child: ScrollOnExpand( + child: ExpandablePanel( + header: MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: widget.title, + makeTextBold: true, + ), + isExpandable: true, + leadingIcon: widget.leadingIcon, + trailingIcon: Icons.expand_more, + menuItemColor: enteColorScheme.fillFaint, + expandableController: expandableController, ), - isHeaderOfExpansion: true, - leadingIcon: widget.leadingIcon, - trailingIcon: Icons.expand_more, - menuItemColor: enteColorScheme.fillFaint, - expandableController: expandableController, + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: widget.selectionOptionsWidget, + ), + theme: getExpandableTheme(context), + controller: expandableController, ), - collapsed: const SizedBox.shrink(), - expanded: widget.selectionOptionsWidget, - theme: getExpandableTheme(context), - controller: expandableController, ), ), ), diff --git a/lib/ui/components/home_header_widget.dart b/lib/ui/components/home_header_widget.dart index 0f8bfc1f0..b43c3c758 100644 --- a/lib/ui/components/home_header_widget.dart +++ b/lib/ui/components/home_header_widget.dart @@ -1,8 +1,7 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/opened_settings_event.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; import 'package:photos/ui/viewer/search/search_widget.dart'; class HomeHeaderWidget extends StatefulWidget { @@ -17,30 +16,23 @@ class HomeHeaderWidget extends StatefulWidget { class _HomeHeaderWidgetState extends State { @override Widget build(BuildContext context) { - final hasNotch = window.viewPadding.top > 65; - return Padding( - padding: EdgeInsets.fromLTRB(4, hasNotch ? 4 : 8, 4, 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - visualDensity: const VisualDensity(horizontal: -2, vertical: -2), - onPressed: () { - Scaffold.of(context).openDrawer(); - Bus.instance.fire(OpenedSettingsEvent()); - }, - splashColor: Colors.transparent, - icon: const Icon( - Icons.menu_outlined, - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: widget.centerWidget, - ), - const SearchIconWidget(), - ], - ), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButtonWidget( + iconButtonType: IconButtonType.primary, + icon: Icons.menu_outlined, + onTap: () { + Scaffold.of(context).openDrawer(); + Bus.instance.fire(OpenedSettingsEvent()); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: widget.centerWidget, + ), + const SearchIconWidget(), + ], ); } } diff --git a/lib/ui/components/icon_button_widget.dart b/lib/ui/components/icon_button_widget.dart new file mode 100644 index 000000000..2bee00ca1 --- /dev/null +++ b/lib/ui/components/icon_button_widget.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; + +enum IconButtonType { + primary, + secondary, + rounded, +} + +class IconButtonWidget extends StatefulWidget { + final IconButtonType iconButtonType; + final IconData icon; + final bool disableGestureDetector; + final VoidCallback? onTap; + final Color? defaultColor; + final Color? pressedColor; + final Color? iconColor; + const IconButtonWidget({ + required this.icon, + required this.iconButtonType, + this.disableGestureDetector = false, + this.onTap, + this.defaultColor, + this.pressedColor, + this.iconColor, + super.key, + }); + + @override + State createState() => _IconButtonWidgetState(); +} + +class _IconButtonWidgetState extends State { + Color? iconStateColor; + @override + void didChangeDependencies() { + setState(() { + iconStateColor = null; + }); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + iconStateColor ?? + (iconStateColor = widget.defaultColor ?? + (widget.iconButtonType == IconButtonType.rounded + ? colorTheme.fillFaint + : null)); + return widget.disableGestureDetector + ? _iconButton(colorTheme) + : GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + onTap: widget.onTap, + child: _iconButton(colorTheme), + ); + } + + Widget _iconButton(EnteColorScheme colorTheme) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 20), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: iconStateColor, + ), + child: Icon( + widget.icon, + color: widget.iconColor ?? + (widget.iconButtonType == IconButtonType.secondary + ? colorTheme.strokeMuted + : colorTheme.strokeBase), + size: 24, + ), + ), + ); + } + + _onTapDown(details) { + final colorTheme = getEnteColorScheme(context); + setState(() { + iconStateColor = widget.pressedColor ?? + (widget.iconButtonType == IconButtonType.rounded + ? colorTheme.fillMuted + : colorTheme.fillFaint); + }); + } + + _onTapUp(details) { + Future.delayed(const Duration(milliseconds: 100), () { + setState(() { + iconStateColor = null; + }); + }); + } + + _onTapCancel() { + setState(() { + iconStateColor = null; + }); + } +} diff --git a/lib/ui/components/menu_item_widget.dart b/lib/ui/components/menu_item_widget.dart index 28a179859..718deafaf 100644 --- a/lib/ui/components/menu_item_widget.dart +++ b/lib/ui/components/menu_item_widget.dart @@ -4,11 +4,15 @@ import 'package:photos/ente_theme_data.dart'; class MenuItemWidget extends StatefulWidget { final Widget captionedTextWidget; - final bool isHeaderOfExpansion; -// leading icon can be passed without specifing size of icon, this component sets size to 20x20 irrespective of passed icon's size + final bool isExpandable; + + /// leading icon can be passed without specifing size of icon, + /// this component sets size to 20x20 irrespective of passed icon's size final IconData? leadingIcon; final Color? leadingIconColor; -// trailing icon can be passed without size as default size set by flutter is what this component expects + + /// trailing icon can be passed without size as default size set by + /// flutter is what this component expects final IconData? trailingIcon; final Widget? trailingSwitch; final bool trailingIconIsMuted; @@ -17,10 +21,16 @@ class MenuItemWidget extends StatefulWidget { final Color? menuItemColor; final bool alignCaptionedTextToLeft; final double borderRadius; + final Color? pressedColor; final ExpandableController? expandableController; + final bool isBottomBorderRadiusRemoved; + final bool isTopBorderRadiusRemoved; + + /// disable gesture detector if not used + final bool isGestureDetectorDisabled; const MenuItemWidget({ required this.captionedTextWidget, - this.isHeaderOfExpansion = false, + this.isExpandable = false, this.leadingIcon, this.leadingIconColor, this.trailingIcon, @@ -31,7 +41,11 @@ class MenuItemWidget extends StatefulWidget { this.menuItemColor, this.alignCaptionedTextToLeft = false, this.borderRadius = 4.0, + this.pressedColor, this.expandableController, + this.isBottomBorderRadiusRemoved = false, + this.isTopBorderRadiusRemoved = false, + this.isGestureDetectorDisabled = false, Key? key, }) : super(key: key); @@ -40,8 +54,10 @@ class MenuItemWidget extends StatefulWidget { } class _MenuItemWidgetState extends State { + Color? menuItemColor; @override void initState() { + menuItemColor = widget.menuItemColor; if (widget.expandableController != null) { widget.expandableController!.addListener(() { setState(() {}); @@ -50,6 +66,12 @@ class _MenuItemWidgetState extends State { super.initState(); } + @override + void didChangeDependencies() { + menuItemColor = widget.menuItemColor; + super.didChangeDependencies(); + } + @override void dispose() { if (widget.expandableController != null) { @@ -60,11 +82,14 @@ class _MenuItemWidgetState extends State { @override Widget build(BuildContext context) { - return widget.isHeaderOfExpansion + return widget.isExpandable || widget.isGestureDetectorDisabled ? menuItemWidget(context) : GestureDetector( onTap: widget.onTap, onDoubleTap: widget.onDoubleTap, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onCancel, child: menuItemWidget(context), ); } @@ -73,21 +98,25 @@ class _MenuItemWidgetState extends State { final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; final borderRadius = Radius.circular(widget.borderRadius); final isExpanded = widget.expandableController?.value; - final bottomBorderRadius = isExpanded != null && isExpanded + final bottomBorderRadius = + (isExpanded != null && isExpanded) || widget.isBottomBorderRadiusRemoved + ? const Radius.circular(0) + : borderRadius; + final topBorderRadius = widget.isTopBorderRadiusRemoved ? const Radius.circular(0) : borderRadius; return AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 20), width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.only(left: 16, right: 12), decoration: BoxDecoration( borderRadius: BorderRadius.only( - topLeft: borderRadius, - topRight: borderRadius, + topLeft: topBorderRadius, + topRight: topBorderRadius, bottomLeft: bottomBorderRadius, bottomRight: bottomBorderRadius, ), - color: widget.menuItemColor, + color: menuItemColor, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -139,4 +168,25 @@ class _MenuItemWidgetState extends State { ), ); } + + void _onTapDown(details) { + setState(() { + menuItemColor = widget.pressedColor ?? widget.menuItemColor; + }); + } + + void _onTapUp(details) { + Future.delayed( + const Duration(milliseconds: 100), + () => setState(() { + menuItemColor = widget.menuItemColor; + }), + ); + } + + void _onCancel() { + setState(() { + menuItemColor = widget.menuItemColor; + }); + } } diff --git a/lib/ui/components/menu_section_description_widget.dart b/lib/ui/components/menu_section_description_widget.dart new file mode 100644 index 000000000..a9445ddde --- /dev/null +++ b/lib/ui/components/menu_section_description_widget.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; + +class MenuSectionDescriptionWidget extends StatelessWidget { + final String content; + const MenuSectionDescriptionWidget({required this.content, super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Text( + content, + style: getEnteTextTheme(context) + .mini + .copyWith(color: getEnteColorScheme(context).textMuted), + ), + ); + } +} diff --git a/lib/ui/components/notification_warning_widget.dart b/lib/ui/components/notification_warning_widget.dart index 7763e233e..7f39468bb 100644 --- a/lib/ui/components/notification_warning_widget.dart +++ b/lib/ui/components/notification_warning_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/text_style.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; class NotificationWarningWidget extends StatelessWidget { final IconData warningIcon; @@ -33,8 +34,9 @@ class NotificationWarningWidget extends StatelessWidget { color: warning500, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Icon( warningIcon, @@ -50,23 +52,14 @@ class NotificationWarningWidget extends StatelessWidget { ), ), const SizedBox(width: 12), - ClipOval( - child: Material( - color: fillFaintDark, - child: InkWell( - splashColor: Colors.red, // Splash color - onTap: onTap, - child: SizedBox( - width: 40, - height: 40, - child: Icon( - actionIcon, - color: Colors.white, - ), - ), - ), - ), - ), + IconButtonWidget( + icon: actionIcon, + iconButtonType: IconButtonType.rounded, + iconColor: strokeBaseDark, + defaultColor: fillFaintDark, + pressedColor: fillMutedDark, + onTap: onTap, + ) ], ), ), diff --git a/lib/ui/components/title_bar_title_widget.dart b/lib/ui/components/title_bar_title_widget.dart new file mode 100644 index 000000000..139bfb948 --- /dev/null +++ b/lib/ui/components/title_bar_title_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; + +class TitleBarTitleWidget extends StatelessWidget { + final String? title; + final bool isTitleH2; + final IconData? icon; + const TitleBarTitleWidget({ + this.title, + this.isTitleH2 = false, + this.icon, + super.key, + }); + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + final colorTheme = getEnteColorScheme(context); + if (title != null) { + if (icon != null) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title!, + style: textTheme.h3Bold, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const SizedBox(width: 8), + Icon(icon, size: 20, color: colorTheme.strokeMuted), + ], + ); + } + if (isTitleH2) { + return Text( + title!, + style: textTheme.h2Bold, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else { + return Text( + title!, + style: textTheme.h3Bold, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + } + + return const SizedBox.shrink(); + } +} diff --git a/lib/ui/components/title_bar_widget.dart b/lib/ui/components/title_bar_widget.dart new file mode 100644 index 000000000..8cc009ec1 --- /dev/null +++ b/lib/ui/components/title_bar_widget.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; + +class TitleBarWidget extends StatelessWidget { + final IconButtonWidget? leading; + final String? title; + final String? caption; + final Widget? flexibleSpaceTitle; + final String? flexibleSpaceCaption; + final List? actionIcons; + final bool isTitleH2WithoutLeading; + final bool isFlexibleSpaceDisabled; + final bool isOnTopOfScreen; + const TitleBarWidget({ + this.leading, + this.title, + this.caption, + this.flexibleSpaceTitle, + this.flexibleSpaceCaption, + this.actionIcons, + this.isTitleH2WithoutLeading = false, + this.isFlexibleSpaceDisabled = false, + this.isOnTopOfScreen = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + const toolbarHeight = 48.0; + final textTheme = getEnteTextTheme(context); + final colorTheme = getEnteColorScheme(context); + return SliverAppBar( + primary: isOnTopOfScreen ? true : false, + toolbarHeight: toolbarHeight, + leadingWidth: 48, + automaticallyImplyLeading: false, + pinned: true, + expandedHeight: isFlexibleSpaceDisabled ? toolbarHeight : 102, + centerTitle: false, + titleSpacing: 4, + title: Padding( + padding: EdgeInsets.only(left: isTitleH2WithoutLeading ? 16 : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + title == null + ? const SizedBox.shrink() + : Text( + title!, + style: isTitleH2WithoutLeading + ? textTheme.h2Bold + : textTheme.largeBold, + ), + caption == null || isTitleH2WithoutLeading + ? const SizedBox.shrink() + : Text( + caption!, + style: textTheme.mini.copyWith(color: colorTheme.textMuted), + ) + ], + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: _actionsWithPaddingInBetween(), + ), + ), + ], + leading: isTitleH2WithoutLeading + ? null + : leading ?? + IconButtonWidget( + icon: Icons.arrow_back_outlined, + iconButtonType: IconButtonType.primary, + onTap: () { + Navigator.pop(context); + }, + ), + flexibleSpace: isFlexibleSpaceDisabled + ? null + : FlexibleSpaceBar( + background: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: toolbarHeight), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + flexibleSpaceTitle == null + ? const SizedBox.shrink() + : flexibleSpaceTitle!, + flexibleSpaceCaption == null + ? const SizedBox.shrink() + : Text( + flexibleSpaceCaption!, + style: textTheme.small.copyWith( + color: colorTheme.textMuted, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ) + ], + ), + ), + ], + ), + ), + ), + ); + } + + _actionsWithPaddingInBetween() { + if (actionIcons == null) { + return [const SizedBox.shrink()]; + } + final actions = []; + bool addWhiteSpace = false; + final length = actionIcons!.length; + int index = 0; + if (length == 0) { + return [const SizedBox.shrink()]; + } + if (length == 1) { + return actionIcons; + } + while (index < length) { + if (!addWhiteSpace) { + actions.add(actionIcons![index]); + index++; + addWhiteSpace = true; + } else { + actions.add(const SizedBox(width: 4)); + addWhiteSpace = false; + } + } + return actions; + } +} diff --git a/lib/ui/components/toggle_switch_widget.dart b/lib/ui/components/toggle_switch_widget.dart index 2924dab1b..995dcbc63 100644 --- a/lib/ui/components/toggle_switch_widget.dart +++ b/lib/ui/components/toggle_switch_widget.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; +import 'package:photos/ui/common/loading_widget.dart'; +import 'package:photos/utils/debouncer.dart'; -typedef OnChangedCallBack = void Function(bool); +enum ExecutionState { + idle, + inProgress, + successful, +} + +typedef OnChangedCallBack = Future Function(); +typedef ValueCallBack = bool Function(); class ToggleSwitchWidget extends StatefulWidget { - final bool value; + final ValueCallBack value; final OnChangedCallBack onChanged; const ToggleSwitchWidget({ required this.value, @@ -17,24 +26,112 @@ class ToggleSwitchWidget extends StatefulWidget { } class _ToggleSwitchWidgetState extends State { + late bool toggleValue; + ExecutionState executionState = ExecutionState.idle; + final _debouncer = Debouncer(const Duration(milliseconds: 300)); + @override + void initState() { + toggleValue = widget.value.call(); + super.initState(); + } + @override Widget build(BuildContext context) { final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: SizedBox( - height: 30, - child: FittedBox( - fit: BoxFit.contain, - child: Switch.adaptive( - activeColor: enteColorScheme.primary400, - inactiveTrackColor: enteColorScheme.fillMuted, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: widget.value, - onChanged: widget.onChanged, + final Widget stateIcon = _stateIcon(enteColorScheme); + + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 2), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 175), + switchInCurve: Curves.easeInExpo, + switchOutCurve: Curves.easeOutExpo, + child: stateIcon, ), ), - ), + SizedBox( + height: 31, + child: FittedBox( + fit: BoxFit.contain, + child: Switch.adaptive( + activeColor: enteColorScheme.primary400, + inactiveTrackColor: enteColorScheme.fillMuted, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: toggleValue, + onChanged: (negationOfToggleValue) async { + setState(() { + toggleValue = negationOfToggleValue; + //start showing inProgress statu icons if toggle takes more than debounce time + _debouncer.run( + () => Future( + () { + setState(() { + executionState = ExecutionState.inProgress; + }); + }, + ), + ); + }); + final Stopwatch stopwatch = Stopwatch()..start(); + await widget.onChanged.call(); + //for toggle feedback on short unsuccessful onChanged + await _feedbackOnUnsuccessfulToggle(stopwatch); + //debouncer gets canceled if onChanged takes less than debounce time + _debouncer.cancelDebounce(); + setState(() { + final newValue = widget.value.call(); + //if onchanged on toggle is successful + if (toggleValue == newValue) { + if (executionState == ExecutionState.inProgress) { + executionState = ExecutionState.successful; + Future.delayed(const Duration(seconds: 2), () { + setState(() { + executionState = ExecutionState.idle; + }); + }); + } + } else { + toggleValue = !toggleValue; + executionState = ExecutionState.idle; + } + }); + }, + ), + ), + ), + ], ); } + + Widget _stateIcon(enteColorScheme) { + if (executionState == ExecutionState.idle) { + return const SizedBox(width: 24); + } else if (executionState == ExecutionState.inProgress) { + return EnteLoadingWidget( + color: enteColorScheme.strokeMuted, + ); + } else if (executionState == ExecutionState.successful) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Icon( + Icons.check_outlined, + size: 22, + color: enteColorScheme.primary500, + ), + ); + } else { + return const SizedBox(width: 24); + } + } + + Future _feedbackOnUnsuccessfulToggle(Stopwatch stopwatch) async { + final timeElapsed = stopwatch.elapsedMilliseconds; + if (timeElapsed < 200) { + await Future.delayed( + Duration(milliseconds: 200 - timeElapsed), + ); + } + } } diff --git a/lib/ui/create_collection_page.dart b/lib/ui/create_collection_page.dart index e5d076614..a328010c5 100644 --- a/lib/ui/create_collection_page.dart +++ b/lib/ui/create_collection_page.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:photos/core/configuration.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/collection.dart'; @@ -23,7 +24,7 @@ import 'package:photos/utils/share_util.dart'; import 'package:photos/utils/toast_util.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -enum CollectionActionType { addFiles, moveFiles, restoreFiles } +enum CollectionActionType { addFiles, moveFiles, restoreFiles, unHide } String _actionName(CollectionActionType type, bool plural) { final titleSuffix = (plural ? "s" : ""); @@ -38,6 +39,9 @@ String _actionName(CollectionActionType type, bool plural) { case CollectionActionType.restoreFiles: text = "Restore file"; break; + case CollectionActionType.unHide: + text = "Unhide file"; + break; } return text + titleSuffix; } @@ -189,8 +193,16 @@ class _CreateCollectionPageState extends State { } Future> _getCollectionsWithThumbnail() async { - final List collectionsWithThumbnail = - await CollectionsService.instance.getCollectionsWithThumbnails(); + final List collectionsWithThumbnail = []; + final latestCollectionFiles = + await CollectionsService.instance.getLatestCollectionFiles(); + for (final file in latestCollectionFiles) { + final c = + CollectionsService.instance.getCollectionByID(file.collectionID); + if (c.owner.id == Configuration.instance.getUserID() && !c.isHidden()) { + collectionsWithThumbnail.add(CollectionWithThumbnail(c, file)); + } + } collectionsWithThumbnail.sort((first, second) { return compareAsciiLowerCaseNatural( first.collection.name ?? "", @@ -273,6 +285,8 @@ class _CreateCollectionPageState extends State { return _addToCollection(collectionID); case CollectionActionType.moveFiles: return _moveFilesToCollection(collectionID); + case CollectionActionType.unHide: + return _moveFilesToCollection(collectionID); case CollectionActionType.restoreFiles: return _restoreFilesToCollection(collectionID); } @@ -280,7 +294,10 @@ class _CreateCollectionPageState extends State { } Future _moveFilesToCollection(int toCollectionID) async { - final dialog = createProgressDialog(context, "Moving files to album..."); + final String message = widget.actionType == CollectionActionType.moveFiles + ? "Moving files to album..." + : "Unhiding files to album"; + final dialog = createProgressDialog(context, message); await dialog.show(); try { final int fromCollectionID = diff --git a/lib/ui/grant_permissions_widget.dart b/lib/ui/home/grant_permissions_widget.dart similarity index 100% rename from lib/ui/grant_permissions_widget.dart rename to lib/ui/home/grant_permissions_widget.dart diff --git a/lib/ui/header_error_widget.dart b/lib/ui/home/header_error_widget.dart similarity index 100% rename from lib/ui/header_error_widget.dart rename to lib/ui/home/header_error_widget.dart diff --git a/lib/ui/home/header_widget.dart b/lib/ui/home/header_widget.dart new file mode 100644 index 000000000..0d9ccf2ee --- /dev/null +++ b/lib/ui/home/header_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/ui/home/memories_widget.dart'; +import 'package:photos/ui/home/status_bar_widget.dart'; + +class HeaderWidget extends StatelessWidget { + static const _memoriesWidget = MemoriesWidget(); + static const _statusBarWidget = StatusBarWidget(); + + const HeaderWidget({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + Logger("Header").info("Building header widget"); + const list = [ + _statusBarWidget, + _memoriesWidget, + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: list, + ); + } +} diff --git a/lib/ui/home/home_bottom_nav_bar.dart b/lib/ui/home/home_bottom_nav_bar.dart new file mode 100644 index 000000000..2ec79c802 --- /dev/null +++ b/lib/ui/home/home_bottom_nav_bar.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/ente_theme_data.dart'; +import 'package:photos/events/tab_changed_event.dart'; +import 'package:photos/models/selected_files.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/effects.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/nav_bar.dart'; + +class HomeBottomNavigationBar extends StatefulWidget { + const HomeBottomNavigationBar( + this.selectedFiles, { + required this.selectedTabIndex, + Key? key, + }) : super(key: key); + + final SelectedFiles selectedFiles; + final int selectedTabIndex; + + @override + State createState() => + _HomeBottomNavigationBarState(); +} + +class _HomeBottomNavigationBarState extends State { + late StreamSubscription _tabChangedEventSubscription; + int currentTabIndex = 0; + + @override + void initState() { + super.initState(); + currentTabIndex = widget.selectedTabIndex; + widget.selectedFiles.addListener(() { + setState(() {}); + }); + _tabChangedEventSubscription = + Bus.instance.on().listen((event) { + if (event.source != TabChangedEventSource.tabBar) { + debugPrint( + '${(TabChangedEvent).toString()} index changed from ' + '$currentTabIndex to ${event.selectedIndex} via ${event.source}', + ); + if (mounted) { + setState(() { + currentTabIndex = event.selectedIndex; + }); + } + } else if (event.source == TabChangedEventSource.tabBar && + currentTabIndex == event.selectedIndex) { + // user tapped on the currently selected index on the tapBar + Bus.instance.fire(TabDoubleTapEvent(currentTabIndex)); + } + }); + } + + @override + void dispose() { + _tabChangedEventSubscription.cancel(); + super.dispose(); + } + + void _onTabChange(int index, {String mode = 'tabChanged'}) { + debugPrint("_TabChanged called via method $mode"); + Bus.instance.fire( + TabChangedEvent( + index, + TabChangedEventSource.tabBar, + ), + ); + } + + @override + Widget build(BuildContext context) { + final bool filesAreSelected = widget.selectedFiles.files.isNotEmpty; + final enteColorScheme = getEnteColorScheme(context); + final navBarBlur = + MediaQuery.of(context).platformBrightness == Brightness.light + ? blurBase + : blurMuted; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + height: filesAreSelected ? 0 : 56, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: filesAreSelected ? 0.0 : 1.0, + curve: Curves.easeIn, + child: IgnorePointer( + ignoring: filesAreSelected, + child: ListView( + physics: const NeverScrollableScrollPhysics(), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(32), + child: Container( + alignment: Alignment.bottomCenter, + height: 48, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: navBarBlur, + sigmaY: navBarBlur, + ), + child: GNav( + curve: Curves.easeOutExpo, + backgroundColor: + getEnteColorScheme(context).fillMuted, + mainAxisAlignment: MainAxisAlignment.center, + rippleColor: Colors.white.withOpacity(0.1), + activeColor: Theme.of(context) + .colorScheme + .gNavBarActiveColor, + iconSize: 24, + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + duration: const Duration(milliseconds: 200), + gap: 0, + tabBorderRadius: 32, + tabBackgroundColor: Theme.of(context) + .colorScheme + .gNavBarActiveColor, + haptic: false, + tabs: [ + GButton( + margin: const EdgeInsets.fromLTRB(8, 6, 10, 6), + icon: Icons.home_rounded, + iconColor: enteColorScheme.tabIcon, + iconActiveColor: strokeBaseLight, + text: '', + onPressed: () { + _onTabChange( + 0, + mode: "OnPressed", + ); // To take care of occasional missing events + }, + ), + GButton( + margin: const EdgeInsets.fromLTRB(10, 6, 10, 6), + icon: Icons.collections_rounded, + iconColor: enteColorScheme.tabIcon, + iconActiveColor: strokeBaseLight, + text: '', + onPressed: () { + _onTabChange( + 1, + mode: "OnPressed", + ); // To take care of occasional missing + // events + }, + ), + GButton( + margin: const EdgeInsets.fromLTRB(10, 6, 8, 6), + icon: Icons.people_outlined, + iconColor: enteColorScheme.tabIcon, + iconActiveColor: strokeBaseLight, + text: '', + onPressed: () { + _onTabChange( + 2, + mode: "OnPressed", + ); // To take care + // of occasional missing events + }, + ), + ], + selectedIndex: currentTabIndex, + onTabChange: _onTabChange, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/home/home_gallery_widget.dart b/lib/ui/home/home_gallery_widget.dart new file mode 100644 index 000000000..57eb99841 --- /dev/null +++ b/lib/ui/home/home_gallery_widget.dart @@ -0,0 +1,88 @@ +// @dart=2.9 +import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/db/files_db.dart'; +import 'package:photos/events/backup_folders_updated_event.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/events/force_reload_home_gallery_event.dart'; +import 'package:photos/events/local_photos_updated_event.dart'; +import 'package:photos/models/file_load_result.dart'; +import 'package:photos/models/selected_files.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/services/ignored_files_service.dart'; +import 'package:photos/ui/viewer/gallery/gallery.dart'; + +class HomeGalleryWidget extends StatelessWidget { + final Widget header; + final Widget footer; + final SelectedFiles selectedFiles; + + const HomeGalleryWidget({ + Key key, + this.header, + this.footer, + this.selectedFiles, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final double bottomSafeArea = MediaQuery.of(context).padding.bottom; + final gallery = Gallery( + asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async { + final ownerID = Configuration.instance.getUserID(); + final hasSelectedAllForBackup = + Configuration.instance.hasSelectedAllFoldersForBackup(); + final collectionsToHide = + CollectionsService.instance.collectionsHiddenFromTimeline(); + FileLoadResult result; + if (hasSelectedAllForBackup) { + result = await FilesDB.instance.getAllLocalAndUploadedFiles( + creationStartTime, + creationEndTime, + ownerID, + limit: limit, + asc: asc, + ignoredCollectionIDs: collectionsToHide, + ); + } else { + result = await FilesDB.instance.getAllPendingOrUploadedFiles( + creationStartTime, + creationEndTime, + ownerID, + limit: limit, + asc: asc, + ignoredCollectionIDs: collectionsToHide, + ); + } + + // hide ignored files from home page UI + final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs; + result.files.removeWhere( + (f) => + f.uploadedFileID == null && + IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f), + ); + return result; + }, + reloadEvent: Bus.instance.on(), + removalEventTypes: const { + EventType.deletedFromRemote, + EventType.deletedFromEverywhere, + EventType.archived, + EventType.hide, + }, + forceReloadEvents: [ + Bus.instance.on(), + Bus.instance.on(), + ], + tagPrefix: "home_gallery", + selectedFiles: selectedFiles, + header: header, + footer: footer, + // scrollSafe area -> SafeArea + Preserver more + Nav Bar buttons + scrollBottomSafeArea: bottomSafeArea + 180, + ); + return gallery; + } +} diff --git a/lib/ui/landing_page_widget.dart b/lib/ui/home/landing_page_widget.dart similarity index 100% rename from lib/ui/landing_page_widget.dart rename to lib/ui/home/landing_page_widget.dart diff --git a/lib/ui/memories_widget.dart b/lib/ui/home/memories_widget.dart similarity index 99% rename from lib/ui/memories_widget.dart rename to lib/ui/home/memories_widget.dart index 39ef03f4b..5ac7d7ec6 100644 --- a/lib/ui/memories_widget.dart +++ b/lib/ui/home/memories_widget.dart @@ -410,9 +410,11 @@ class _FullScreenMemoryState extends State { extents: 1, onPageChanged: (index) async { await MemoriesService.instance.markMemoryAsSeen(widget.memories[index]); - setState(() { - _index = index; - }); + if (mounted) { + setState(() { + _index = index; + }); + } }, physics: _shouldDisableScroll ? const NeverScrollableScrollPhysics() diff --git a/lib/ui/viewer/gallery/gallery_footer_widget.dart b/lib/ui/home/preserve_footer_widget.dart similarity index 88% rename from lib/ui/viewer/gallery/gallery_footer_widget.dart rename to lib/ui/home/preserve_footer_widget.dart index 68064be63..c8d7b4ae7 100644 --- a/lib/ui/viewer/gallery/gallery_footer_widget.dart +++ b/lib/ui/home/preserve_footer_widget.dart @@ -1,5 +1,3 @@ -// @dart=2.9 - import 'package:flutter/material.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photos/services/local_sync_service.dart'; @@ -7,8 +5,8 @@ import 'package:photos/ui/backup_folder_selection_page.dart'; import 'package:photos/ui/common/gradient_button.dart'; import 'package:photos/utils/navigation_util.dart'; -class GalleryFooterWidget extends StatelessWidget { - const GalleryFooterWidget({Key key}) : super(key: key); +class PreserveFooterWidget extends StatelessWidget { + const PreserveFooterWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/ui/home/start_backup_hook_widget.dart b/lib/ui/home/start_backup_hook_widget.dart new file mode 100644 index 000000000..9abdc0d29 --- /dev/null +++ b/lib/ui/home/start_backup_hook_widget.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:photos/services/local_sync_service.dart'; +import 'package:photos/ui/backup_folder_selection_page.dart'; +import 'package:photos/ui/common/gradient_button.dart'; +import 'package:photos/utils/navigation_util.dart'; + +class StartBackupHookWidget extends StatelessWidget { + final Widget headerWidget; + + const StartBackupHookWidget({super.key, required this.headerWidget}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + headerWidget, + Padding( + padding: const EdgeInsets.only(top: 64), + child: Image.asset( + "assets/onboarding_safe.png", + height: 206, + ), + ), + Text( + 'No photos are being backed up right now', + style: Theme.of(context) + .textTheme + .caption! + .copyWith(fontFamily: 'Inter-Medium', fontSize: 16), + ), + Center( + child: Material( + type: MaterialType.transparency, + child: Container( + width: double.infinity, + height: 64, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: GradientButton( + onTap: () async { + if (LocalSyncService.instance + .hasGrantedLimitedPermissions()) { + PhotoManager.presentLimited(); + } else { + routeToPage( + context, + const BackupFolderSelectionPage( + buttonText: "Start backup", + ), + ); + } + }, + text: "Start backup", + ), + ), + ), + ), + const Padding(padding: EdgeInsets.all(50)), + ], + ); + } +} diff --git a/lib/ui/status_bar_widget.dart b/lib/ui/home/status_bar_widget.dart similarity index 95% rename from lib/ui/status_bar_widget.dart rename to lib/ui/home/status_bar_widget.dart index bb517dd47..bf67a53e5 100644 --- a/lib/ui/status_bar_widget.dart +++ b/lib/ui/home/status_bar_widget.dart @@ -9,11 +9,11 @@ import 'package:photos/events/notification_event.dart'; import 'package:photos/events/sync_status_update_event.dart'; import 'package:photos/services/sync_service.dart'; import 'package:photos/services/user_remote_flag_service.dart'; +import 'package:photos/theme/text_style.dart'; import 'package:photos/ui/account/verify_recovery_page.dart'; -import 'package:photos/ui/components/brand_title_widget.dart'; import 'package:photos/ui/components/home_header_widget.dart'; import 'package:photos/ui/components/notification_warning_widget.dart'; -import 'package:photos/ui/header_error_widget.dart'; +import 'package:photos/ui/home/header_error_widget.dart'; import 'package:photos/utils/navigation_util.dart'; const double kContainerHeight = 36; @@ -84,9 +84,9 @@ class _StatusBarWidgetState extends State { HomeHeaderWidget( centerWidget: _showStatus ? _showErrorBanner - ? const BrandTitleWidget(size: SizeVarient.medium) + ? const Text("ente", style: brandStyleMedium) : const SyncStatusWidget() - : const BrandTitleWidget(size: SizeVarient.medium), + : const Text("ente", style: brandStyleMedium), ), AnimatedOpacity( opacity: _showErrorBanner ? 1 : 0, @@ -100,9 +100,9 @@ class _StatusBarWidgetState extends State { : const SizedBox.shrink(), UserRemoteFlagService.instance.shouldShowRecoveryVerification() ? NotificationWarningWidget( - warningIcon: Icons.gpp_maybe, + warningIcon: Icons.error_outline, actionIcon: Icons.arrow_forward, - text: "Please ensure you have your 24 word recovery key", + text: "Confirm your recovery key", onTap: () async => { await routeToPage( context, diff --git a/lib/ui/home_widget.dart b/lib/ui/home_widget.dart index 218dfa489..eb85a9ae2 100644 --- a/lib/ui/home_widget.dart +++ b/lib/ui/home_widget.dart @@ -2,62 +2,48 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:move_to_background/move_to_background.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/db/files_db.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/events/account_configured_event.dart'; import 'package:photos/events/backup_folders_updated_event.dart'; -import 'package:photos/events/files_updated_event.dart'; -import 'package:photos/events/force_reload_home_gallery_event.dart'; -import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/permission_granted_event.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/events/sync_status_update_event.dart'; import 'package:photos/events/tab_changed_event.dart'; import 'package:photos/events/trigger_logout_event.dart'; import 'package:photos/events/user_logged_out_event.dart'; -import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/gallery_type.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/collections_service.dart'; -import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/services/update_service.dart'; import 'package:photos/services/user_service.dart'; import 'package:photos/states/user_details_state.dart'; -import 'package:photos/theme/colors.dart'; -import 'package:photos/theme/effects.dart'; import 'package:photos/theme/ente_theme.dart'; -import 'package:photos/ui/backup_folder_selection_page.dart'; import 'package:photos/ui/collections_gallery_widget.dart'; import 'package:photos/ui/common/bottom_shadow.dart'; -import 'package:photos/ui/common/gradient_button.dart'; import 'package:photos/ui/create_collection_page.dart'; import 'package:photos/ui/extents_page_view.dart'; -import 'package:photos/ui/grant_permissions_widget.dart'; -import 'package:photos/ui/landing_page_widget.dart'; +import 'package:photos/ui/home/grant_permissions_widget.dart'; +import 'package:photos/ui/home/header_widget.dart'; +import 'package:photos/ui/home/home_bottom_nav_bar.dart'; +import 'package:photos/ui/home/home_gallery_widget.dart'; +import 'package:photos/ui/home/landing_page_widget.dart'; +import 'package:photos/ui/home/preserve_footer_widget.dart'; +import 'package:photos/ui/home/start_backup_hook_widget.dart'; import 'package:photos/ui/loading_photos_widget.dart'; -import 'package:photos/ui/memories_widget.dart'; -import 'package:photos/ui/nav_bar.dart'; import 'package:photos/ui/settings/app_update_dialog.dart'; import 'package:photos/ui/settings_page.dart'; import 'package:photos/ui/shared_collections_gallery.dart'; -import 'package:photos/ui/status_bar_widget.dart'; -import 'package:photos/ui/viewer/gallery/gallery.dart'; -import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; -import 'package:photos/ui/viewer/gallery/gallery_footer_widget.dart'; import 'package:photos/ui/viewer/gallery/gallery_overlay_widget.dart'; import 'package:photos/utils/dialog_util.dart'; -import 'package:photos/utils/navigation_util.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:uni_links/uni_links.dart'; @@ -81,7 +67,6 @@ class _HomeWidgetState extends State { final PageController _pageController = PageController(); int _selectedTabIndex = 0; - Widget _headerWidgetWithSettingsButton; // for receiving media files // ignore: unused_field @@ -100,15 +85,14 @@ class _HomeWidgetState extends State { @override void initState() { _logger.info("Building initstate"); - _headerWidgetWithSettingsButton = Stack( - children: const [ - _headerWidget, - ], - ); _tabChangedEventSubscription = Bus.instance.on().listen((event) { if (event.source != TabChangedEventSource.pageView) { + debugPrint( + "TabChange going from $_selectedTabIndex to ${event.selectedIndex} souce: ${event.source}", + ); _selectedTabIndex = event.selectedIndex; + // _pageController.jumpToPage(_selectedTabIndex); _pageController.animateToPage( event.selectedIndex, duration: const Duration(milliseconds: 100), @@ -126,34 +110,7 @@ class _HomeWidgetState extends State { }); _triggerLogoutEvent = Bus.instance.on().listen((event) async { - final AlertDialog alert = AlertDialog( - title: const Text("Session expired"), - content: const Text("Please login again"), - actions: [ - TextButton( - child: Text( - "Ok", - style: TextStyle( - color: Theme.of(context).colorScheme.greenAlternative, - ), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop('dialog'); - final dialog = createProgressDialog(context, "Logging out..."); - await dialog.show(); - await Configuration.instance.logout(); - await dialog.hide(); - }, - ), - ], - ); - - showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - ); + await _autoLogoutAlert(); }); _loggedOutEvent = Bus.instance.on().listen((event) { _logger.info('logged out, selectTab index to 0'); @@ -218,6 +175,37 @@ class _HomeWidgetState extends State { super.initState(); } + Future _autoLogoutAlert() async { + final AlertDialog alert = AlertDialog( + title: const Text("Session expired"), + content: const Text("Please login again"), + actions: [ + TextButton( + child: Text( + "Ok", + style: TextStyle( + color: Theme.of(context).colorScheme.greenAlternative, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + final dialog = createProgressDialog(context, "Logging out..."); + await dialog.show(); + await Configuration.instance.logout(); + await dialog.hide(); + }, + ), + ], + ); + + await showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } + @override void dispose() { _tabChangedEventSubscription.cancel(); @@ -262,8 +250,8 @@ class _HomeWidgetState extends State { child: WillPopScope( child: Scaffold( drawerScrimColor: getEnteColorScheme(context).strokeFainter, - drawerEnableOpenDragGesture: - false, //using a hack instead of enabling this as enabling this will create other problems + drawerEnableOpenDragGesture: false, + //using a hack instead of enabling this as enabling this will create other problems drawer: enableDrawer ? ConstrainedBox( constraints: const BoxConstraints(maxWidth: 428), @@ -345,8 +333,12 @@ class _HomeWidgetState extends State { physics: const BouncingScrollPhysics(), children: [ showBackupFolderHook - ? _getBackupFolderSelectionHook() - : _getMainGalleryWidget(), + ? const StartBackupHookWidget(headerWidget: _headerWidget) + : HomeGalleryWidget( + header: _headerWidget, + footer: const PreserveFooterWidget(), + selectedFiles: _selectedFiles, + ), _deviceFolderGalleryWidget, _sharedCollectionGallery, ], @@ -422,347 +414,4 @@ class _HomeWidgetState extends State { final ott = Uri.parse(link).queryParameters["ott"]; UserService.instance.verifyEmail(context, ott); } - - Widget _getMainGalleryWidget() { - Widget header; - if (_selectedFiles.files.isEmpty) { - header = _headerWidgetWithSettingsButton; - } else { - header = _headerWidget; - } - final gallery = Gallery( - asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async { - final ownerID = Configuration.instance.getUserID(); - final hasSelectedAllForBackup = - Configuration.instance.hasSelectedAllFoldersForBackup(); - final archivedCollectionIds = - CollectionsService.instance.getArchivedCollections(); - FileLoadResult result; - if (hasSelectedAllForBackup) { - result = await FilesDB.instance.getAllLocalAndUploadedFiles( - creationStartTime, - creationEndTime, - ownerID, - limit: limit, - asc: asc, - ignoredCollectionIDs: archivedCollectionIds, - ); - } else { - result = await FilesDB.instance.getAllPendingOrUploadedFiles( - creationStartTime, - creationEndTime, - ownerID, - limit: limit, - asc: asc, - ignoredCollectionIDs: archivedCollectionIds, - ); - } - - // hide ignored files from home page UI - final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs; - result.files.removeWhere( - (f) => - f.uploadedFileID == null && - IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f), - ); - return result; - }, - reloadEvent: Bus.instance.on(), - removalEventTypes: const { - EventType.deletedFromRemote, - EventType.deletedFromEverywhere, - EventType.archived, - }, - forceReloadEvents: [ - Bus.instance.on(), - Bus.instance.on(), - ], - tagPrefix: "home_gallery", - selectedFiles: _selectedFiles, - header: header, - footer: const GalleryFooterWidget(), - ); - return Stack( - children: [ - Container( - child: gallery, - ), - HomePageAppBar(_selectedFiles), - ], - ); - } - - Widget _getBackupFolderSelectionHook() { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _headerWidgetWithSettingsButton, - Padding( - padding: const EdgeInsets.only(top: 64), - child: Image.asset( - "assets/onboarding_safe.png", - height: 206, - ), - ), - Text( - 'No photos are being backed up right now', - style: Theme.of(context) - .textTheme - .caption - .copyWith(fontFamily: 'Inter-Medium', fontSize: 16), - ), - Center( - child: Material( - type: MaterialType.transparency, - child: Container( - width: double.infinity, - height: 64, - padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), - child: GradientButton( - onTap: () async { - if (LocalSyncService.instance - .hasGrantedLimitedPermissions()) { - PhotoManager.presentLimited(); - } else { - routeToPage( - context, - const BackupFolderSelectionPage( - buttonText: "Start backup", - ), - ); - } - }, - text: "Start backup", - ), - ), - ), - ), - const Padding(padding: EdgeInsets.all(50)), - ], - ); - } -} - -class HomePageAppBar extends StatefulWidget { - const HomePageAppBar( - this.selectedFiles, { - Key key, - }) : super(key: key); - - final SelectedFiles selectedFiles; - - @override - State createState() => _HomePageAppBarState(); -} - -class _HomePageAppBarState extends State { - @override - void initState() { - super.initState(); - widget.selectedFiles.addListener(() { - setState(() {}); - }); - } - - @override - Widget build(BuildContext context) { - final appBar = SizedBox( - height: 60, - child: GalleryAppBarWidget( - GalleryType.homepage, - null, - widget.selectedFiles, - ), - ); - if (widget.selectedFiles.files.isEmpty) { - return IgnorePointer(child: appBar); - } else { - return appBar; - } - } -} - -class HomeBottomNavigationBar extends StatefulWidget { - const HomeBottomNavigationBar( - this.selectedFiles, { - this.selectedTabIndex, - Key key, - }) : super(key: key); - - final SelectedFiles selectedFiles; - final int selectedTabIndex; - - @override - State createState() => - _HomeBottomNavigationBarState(); -} - -class _HomeBottomNavigationBarState extends State { - StreamSubscription _tabChangedEventSubscription; - int currentTabIndex = 0; - - @override - void initState() { - super.initState(); - currentTabIndex = widget.selectedTabIndex; - widget.selectedFiles.addListener(() { - setState(() {}); - }); - _tabChangedEventSubscription = - Bus.instance.on().listen((event) { - if (event.source != TabChangedEventSource.tabBar) { - debugPrint('index changed to ${event.selectedIndex}'); - if (mounted) { - setState(() { - currentTabIndex = event.selectedIndex; - }); - } - } - }); - } - - @override - void dispose() { - _tabChangedEventSubscription.cancel(); - super.dispose(); - } - - void _onTabChange(int index) { - Bus.instance.fire( - TabChangedEvent( - index, - TabChangedEventSource.tabBar, - ), - ); - } - - @override - Widget build(BuildContext context) { - final bool filesAreSelected = widget.selectedFiles.files.isNotEmpty; - final enteColorScheme = getEnteColorScheme(context); - final navBarBlur = - MediaQuery.of(context).platformBrightness == Brightness.light - ? blurBase - : blurMuted; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - height: filesAreSelected ? 0 : 56, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: filesAreSelected ? 0.0 : 1.0, - curve: Curves.easeIn, - child: IgnorePointer( - ignoring: filesAreSelected, - child: ListView( - physics: const NeverScrollableScrollPhysics(), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(32), - child: Container( - alignment: Alignment.bottomCenter, - height: 48, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: navBarBlur, - sigmaY: navBarBlur, - ), - child: GNav( - curve: Curves.easeOutExpo, - backgroundColor: - getEnteColorScheme(context).fillMuted, - mainAxisAlignment: MainAxisAlignment.center, - rippleColor: Colors.white.withOpacity(0.1), - activeColor: Theme.of(context) - .colorScheme - .gNavBarActiveColor, - iconSize: 24, - padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), - duration: const Duration(milliseconds: 200), - gap: 0, - tabBorderRadius: 32, - tabBackgroundColor: Theme.of(context) - .colorScheme - .gNavBarActiveColor, - haptic: false, - tabs: [ - GButton( - margin: const EdgeInsets.fromLTRB(8, 6, 10, 6), - icon: Icons.home_rounded, - iconColor: enteColorScheme.tabIcon, - iconActiveColor: strokeBaseLight, - text: '', - onPressed: () { - _onTabChange( - 0, - ); // To take care of occasional missing events - }, - ), - GButton( - margin: const EdgeInsets.fromLTRB(10, 6, 10, 6), - icon: Icons.collections_rounded, - iconColor: enteColorScheme.tabIcon, - iconActiveColor: strokeBaseLight, - text: '', - onPressed: () { - _onTabChange( - 1, - ); // To take care of occasional missing events - }, - ), - GButton( - margin: const EdgeInsets.fromLTRB(10, 6, 8, 6), - icon: Icons.people_outlined, - iconColor: enteColorScheme.tabIcon, - iconActiveColor: strokeBaseLight, - text: '', - onPressed: () { - _onTabChange( - 2, - ); // To take care of occasional missing events - }, - ), - ], - selectedIndex: currentTabIndex, - onTabChange: _onTabChange, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class HeaderWidget extends StatelessWidget { - static const _memoriesWidget = MemoriesWidget(); - static const _statusBarWidget = StatusBarWidget(); - - const HeaderWidget({ - Key key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - Logger("Header").info("Building header widget"); - const list = [ - _statusBarWidget, - _memoriesWidget, - ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: list, - ); - } } diff --git a/lib/ui/huge_listview/draggable_scrollbar.dart b/lib/ui/huge_listview/draggable_scrollbar.dart index 002953346..7626670d4 100644 --- a/lib/ui/huge_listview/draggable_scrollbar.dart +++ b/lib/ui/huge_listview/draggable_scrollbar.dart @@ -15,6 +15,7 @@ class DraggableScrollbar extends StatefulWidget { final EdgeInsetsGeometry padding; final int totalCount; final int initialScrollIndex; + final double bottomSafeArea; final int currentFirstIndex; final ValueChanged onChange; final String Function(int) labelTextBuilder; @@ -26,6 +27,7 @@ class DraggableScrollbar extends StatefulWidget { this.backgroundColor = Colors.white, this.drawColor = Colors.grey, this.heightScrollThumb = 80.0, + this.bottomSafeArea = 120, this.padding, this.totalCount = 1, this.initialScrollIndex = 0, @@ -49,7 +51,8 @@ class DraggableScrollbarState extends State double get thumbMin => 0.0; - double get thumbMax => context.size.height - widget.heightScrollThumb; + double get thumbMax => + context.size.height - widget.heightScrollThumb - widget.bottomSafeArea; AnimationController _thumbAnimationController; Animation _thumbAnimation; diff --git a/lib/ui/huge_listview/huge_listview.dart b/lib/ui/huge_listview/huge_listview.dart index 00b450616..9bb16cf18 100644 --- a/lib/ui/huge_listview/huge_listview.dart +++ b/lib/ui/huge_listview/huge_listview.dart @@ -38,6 +38,10 @@ class HugeListView extends StatefulWidget { /// Height of scroll thumb, defaults to 48. final double thumbHeight; + /// Height of bottomSafeArea so that scroll thumb does not become hidden + /// or un-clickable due to footer elements. Default value is 120 + final double bottomSafeArea; + /// Called to build an individual item with the specified [index]. final HugeListViewItemBuilder itemBuilder; @@ -72,6 +76,7 @@ class HugeListView extends StatefulWidget { this.thumbBackgroundColor = Colors.red, // Colors.white, this.thumbDrawColor = Colors.yellow, //Colors.grey, this.thumbHeight = 48.0, + this.bottomSafeArea = 120.0, this.isDraggableScrollbarEnabled = true, this.thumbPadding, }) : super(key: key); @@ -83,6 +88,7 @@ class HugeListView extends StatefulWidget { class HugeListViewState extends State> { final scrollKey = GlobalKey(); final listener = ItemPositionsListener.create(); + int lastIndexJump = -1; dynamic error; @override @@ -131,13 +137,27 @@ class HugeListViewState extends State> { totalCount: widget.totalCount, initialScrollIndex: widget.startIndex, onChange: (position) { - widget.controller - ?.jumpTo(index: (position * widget.totalCount).floor()); + final int currentIndex = _currentFirst(); + final int floorIndex = (position * widget.totalCount).floor(); + final int cielIndex = (position * widget.totalCount).ceil(); + int nextIndexToJump; + if (floorIndex != currentIndex && floorIndex > currentIndex) { + nextIndexToJump = floorIndex; + } else if (cielIndex != currentIndex && cielIndex < currentIndex) { + nextIndexToJump = floorIndex; + } else { + return; + } + if (lastIndexJump != nextIndexToJump) { + lastIndexJump = nextIndexToJump; + widget.controller?.jumpTo(index: nextIndexToJump); + } }, labelTextBuilder: widget.labelTextBuilder, backgroundColor: widget.thumbBackgroundColor, drawColor: widget.thumbDrawColor, heightScrollThumb: widget.thumbHeight, + bottomSafeArea: widget.bottomSafeArea, currentFirstIndex: _currentFirst(), isEnabled: widget.isDraggableScrollbarEnabled, padding: widget.thumbPadding, diff --git a/lib/ui/nav_bar.dart b/lib/ui/nav_bar.dart index 396678c40..a7699ef5a 100644 --- a/lib/ui/nav_bar.dart +++ b/lib/ui/nav_bar.dart @@ -2,8 +2,6 @@ library google_nav_bar; -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -120,19 +118,7 @@ class _GNavState extends State { Colors.transparent, duration: widget.duration ?? const Duration(milliseconds: 500), onPressed: () { - if (!clickable) return; - setState(() { - selectedIndex = widget.tabs.indexOf(t); - clickable = false; - }); - widget.onTabChange(selectedIndex); - - Future.delayed( - widget.duration ?? const Duration(milliseconds: 500), () { - setState(() { - clickable = true; - }); - }); + widget.onTabChange(widget.tabs.indexOf(t)); }, ), ) diff --git a/lib/ui/payment/skip_subscription_widget.dart b/lib/ui/payment/skip_subscription_widget.dart index ae72e0d77..0e4490a3a 100644 --- a/lib/ui/payment/skip_subscription_widget.dart +++ b/lib/ui/payment/skip_subscription_widget.dart @@ -44,7 +44,7 @@ class SkipSubscriptionWidget extends StatelessWidget { BillingService.instance .verifySubscription(freeProductID, "", paymentProvider: "ente"); }, - child: const Text("Continue on free plan"), + child: const Text("Continue on free trial"), ), ); } diff --git a/lib/ui/payment/stripe_subscription_page.dart b/lib/ui/payment/stripe_subscription_page.dart index a56b02498..f4b85d413 100644 --- a/lib/ui/payment/stripe_subscription_page.dart +++ b/lib/ui/payment/stripe_subscription_page.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; @@ -38,7 +37,6 @@ class StripeSubscriptionPage extends StatefulWidget { } class _StripeSubscriptionPageState extends State { - final _logger = Logger("StripeSubscriptionPage"); final _billingService = BillingService.instance; final _userService = UserService.instance; Subscription _currentSubscription; diff --git a/lib/ui/payment/subscription_common_widgets.dart b/lib/ui/payment/subscription_common_widgets.dart index 99df81293..f6270f652 100644 --- a/lib/ui/payment/subscription_common_widgets.dart +++ b/lib/ui/payment/subscription_common_widgets.dart @@ -94,7 +94,7 @@ class ValidityWidget extends StatelessWidget { ); var message = "Renews on $endDate"; if (currentSubscription.productID == freeProductID) { - message = "Free plan valid till $endDate"; + message = "Free trial valid till $endDate"; } else if (currentSubscription.attributes?.isCancelled ?? false) { message = "Your subscription will be cancelled on $endDate"; } diff --git a/lib/ui/payment/subscription_page.dart b/lib/ui/payment/subscription_page.dart index ac0b5e905..28a1ef420 100644 --- a/lib/ui/payment/subscription_page.dart +++ b/lib/ui/payment/subscription_page.dart @@ -368,7 +368,7 @@ class _SubscriptionPageState extends State { planWidgets.add( SubscriptionPlanWidget( storage: _freePlan.storage, - price: "free", + price: "Free trial", period: "", isActive: true, ), diff --git a/lib/ui/payment/subscription_plan_widget.dart b/lib/ui/payment/subscription_plan_widget.dart index 776bd15e1..5c06c7998 100644 --- a/lib/ui/payment/subscription_plan_widget.dart +++ b/lib/ui/payment/subscription_plan_widget.dart @@ -19,7 +19,7 @@ class SubscriptionPlanWidget extends StatelessWidget { String _displayPrice() { final result = price + (period.isNotEmpty ? " / " + period : ""); - return result.isNotEmpty ? result : "Trial plan"; + return price.isNotEmpty ? result : "Free trial"; } @override diff --git a/lib/ui/settings/about_section_widget.dart b/lib/ui/settings/about_section_widget.dart index bc30c017d..d87d6c52c 100644 --- a/lib/ui/settings/about_section_widget.dart +++ b/lib/ui/settings/about_section_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:photos/services/update_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; @@ -47,6 +48,7 @@ class AboutSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Source code", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -61,6 +63,7 @@ class AboutSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Check for updates", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -111,6 +114,7 @@ class AboutMenuItemWidget extends StatelessWidget { captionedTextWidget: CaptionedTextWidget( title: title, ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { diff --git a/lib/ui/settings/account_section_widget.dart b/lib/ui/settings/account_section_widget.dart index 16e2e8fee..b39ca1b3d 100644 --- a/lib/ui/settings/account_section_widget.dart +++ b/lib/ui/settings/account_section_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:photos/services/local_authentication_service.dart'; import 'package:photos/services/user_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/account/change_email_dialog.dart'; import 'package:photos/ui/account/password_entry_page.dart'; import 'package:photos/ui/account/recovery_key_page.dart'; @@ -34,6 +35,7 @@ class AccountSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Recovery key", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -67,6 +69,7 @@ class AccountSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Change email", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -92,6 +95,7 @@ class AccountSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Change password", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { diff --git a/lib/ui/settings/app_version_widget.dart b/lib/ui/settings/app_version_widget.dart index ac9f36b0f..a991dafad 100644 --- a/lib/ui/settings/app_version_widget.dart +++ b/lib/ui/settings/app_version_widget.dart @@ -15,7 +15,6 @@ class AppVersionWidget extends StatefulWidget { class _AppVersionWidgetState extends State { static const kTapThresholdForInspector = 5; static const kConsecutiveTapTimeWindowInMilliseconds = 2000; - static const kDummyDelayDurationInMilliseconds = 1500; int _lastTap; int _consecutiveTaps = 0; diff --git a/lib/ui/settings/backup_section_widget.dart b/lib/ui/settings/backup_section_widget.dart index 633612af1..61fdb4f83 100644 --- a/lib/ui/settings/backup_section_widget.dart +++ b/lib/ui/settings/backup_section_widget.dart @@ -3,18 +3,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:photos/core/configuration.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/backup_status.dart'; import 'package:photos/models/duplicate_files.dart'; import 'package:photos/services/deduplication_service.dart'; import 'package:photos/services/sync_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/backup_folder_selection_page.dart'; -import 'package:photos/ui/common/dialogs.dart'; +import 'package:photos/ui/backup_settings_screen.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget.dart'; -import 'package:photos/ui/components/toggle_switch_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/ui/tools/deduplicate_page.dart'; import 'package:photos/ui/tools/free_space_page.dart'; @@ -48,6 +47,7 @@ class BackupSectionWidgetState extends State { captionedTextWidget: const CaptionedTextWidget( title: "Backed up folders", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () { @@ -62,66 +62,28 @@ class BackupSectionWidgetState extends State { sectionOptionSpacing, MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( - title: "Backup over mobile data", - ), - trailingSwitch: ToggleSwitchWidget( - value: Configuration.instance.shouldBackupOverMobileData(), - onChanged: (value) async { - Configuration.instance.setBackupOverMobileData(value); - setState(() {}); - }, - ), - ), - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: "Backup videos", - ), - trailingSwitch: ToggleSwitchWidget( - value: Configuration.instance.shouldBackupVideos(), - onChanged: (value) async { - Configuration.instance.setShouldBackupVideos(value); - setState(() {}); - }, + title: "Backup settings", ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () { + routeToPage( + context, + const BackupSettingsScreen(), + ); + }, ), sectionOptionSpacing, ]; - if (Platform.isIOS) { - sectionOptions.addAll([ - MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: "Disable auto lock", - ), - trailingSwitch: ToggleSwitchWidget( - value: Configuration.instance.shouldKeepDeviceAwake(), - onChanged: (value) async { - if (value) { - final choice = await showChoiceDialog( - context, - "Disable automatic screen lock when ente is running?", - "This will ensure faster uploads by ensuring your device does not sleep when uploads are in progress.", - firstAction: "No", - secondAction: "Yes", - ); - if (choice != DialogUserChoice.secondChoice) { - return; - } - } - await Configuration.instance.setShouldKeepDeviceAwake(value); - setState(() {}); - }, - ), - ), - sectionOptionSpacing, - ]); - } + sectionOptions.addAll( [ MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( title: "Free up space", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -157,6 +119,7 @@ class BackupSectionWidgetState extends State { captionedTextWidget: const CaptionedTextWidget( title: "Deduplicate files", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { diff --git a/lib/ui/settings/danger_section_widget.dart b/lib/ui/settings/danger_section_widget.dart index 1a33bd862..fd1fadf78 100644 --- a/lib/ui/settings/danger_section_widget.dart +++ b/lib/ui/settings/danger_section_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/services/user_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/account/delete_account_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; @@ -30,6 +31,7 @@ class DangerSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Logout", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () { @@ -41,6 +43,7 @@ class DangerSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Delete account", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () { diff --git a/lib/ui/settings/debug_section_widget.dart b/lib/ui/settings/debug_section_widget.dart index 0b5a209ec..656db646b 100644 --- a/lib/ui/settings/debug_section_widget.dart +++ b/lib/ui/settings/debug_section_widget.dart @@ -6,6 +6,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/services/sync_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget.dart'; @@ -32,6 +33,7 @@ class DebugSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Key attributes", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -43,6 +45,7 @@ class DebugSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Delete Local Import DB", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -55,6 +58,7 @@ class DebugSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Allow auto-upload for ignored files", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { diff --git a/lib/ui/settings/details_section_widget.dart b/lib/ui/settings/details_section_widget.dart deleted file mode 100644 index a2675eaf7..000000000 --- a/lib/ui/settings/details_section_widget.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:photos/models/user_details.dart'; -import 'package:photos/states/user_details_state.dart'; -import 'package:photos/ui/common/loading_widget.dart'; -// ignore: import_of_legacy_library_into_null_safe -import 'package:photos/ui/payment/subscription.dart'; -import 'package:photos/utils/data_util.dart'; - -class DetailsSectionWidget extends StatefulWidget { - const DetailsSectionWidget({Key? key}) : super(key: key); - - @override - State createState() => _DetailsSectionWidgetState(); -} - -class _DetailsSectionWidgetState extends State { - late Image _background; - final _logger = Logger((_DetailsSectionWidgetState).toString()); - - @override - void initState() { - super.initState(); - _background = const Image( - image: AssetImage("assets/storage_card_background.png"), - fit: BoxFit.fill, - ); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // precache background image to avoid flicker - // https://stackoverflow.com/questions/51343735/flutter-image-preload - precacheImage(_background.image, context); - } - - @override - Widget build(BuildContext context) { - final inheritedUserDetails = InheritedUserDetails.of(context); - - if (inheritedUserDetails == null) { - _logger.severe( - (InheritedUserDetails).toString() + - ' not found before ' + - (_DetailsSectionWidgetState).toString() + - ' on tree', - ); - throw Error(); - } else { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () async { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return getSubscriptionPage(); - }, - ), - ); - }, - child: containerForUserDetails(inheritedUserDetails), - ); - } - } - - Widget containerForUserDetails( - InheritedUserDetails inheritedUserDetails, - ) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 428, maxHeight: 175), - child: Stack( - children: [ - Container( - width: double.infinity, - color: Colors.transparent, - child: AspectRatio( - aspectRatio: 2 / 1, - child: _background, - ), - ), - FutureBuilder( - future: inheritedUserDetails.userDetails, - builder: (context, snapshot) { - if (snapshot.hasData) { - return userDetails(snapshot.data as UserDetails); - } - if (snapshot.hasError) { - _logger.severe('failed to load user details', snapshot.error); - return const EnteLoadingWidget(); - } - return const EnteLoadingWidget(); - }, - ), - const Align( - alignment: Alignment.centerRight, - child: Icon( - Icons.chevron_right, - color: Colors.white, - size: 24, - ), - ), - ], - ), - ); - } - - Widget userDetails(UserDetails userDetails) { - return Padding( - padding: const EdgeInsets.only( - top: 20, - bottom: 20, - left: 16, - right: 16, - ), - child: Container( - color: Colors.transparent, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Align( - alignment: Alignment.topLeft, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Storage", - style: Theme.of(context).textTheme.subtitle2!.copyWith( - color: Colors.white.withOpacity(0.7), - ), - ), - Text( - "${convertBytesToReadableFormat(userDetails.getFreeStorage())} of ${convertBytesToReadableFormat(userDetails.getTotalStorage())} free", - style: Theme.of(context) - .textTheme - .headline5! - .copyWith(color: Colors.white), - ), - ], - ), - ), - Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Stack( - children: [ - Container( - color: Colors.white.withOpacity(0.2), - width: MediaQuery.of(context).size.width, - height: 4, - ), - Container( - color: Colors.white.withOpacity(0.75), - width: MediaQuery.of(context).size.width * - ((userDetails.getFamilyOrPersonalUsage()) / - userDetails.getTotalStorage()), - height: 4, - ), - Container( - color: Colors.white, - width: MediaQuery.of(context).size.width * - (userDetails.usage / userDetails.getTotalStorage()), - height: 4, - ), - ], - ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - userDetails.isPartOfFamily() - ? Row( - children: [ - Container( - width: 8.71, - height: 8.99, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - const Padding( - padding: EdgeInsets.only(right: 4), - ), - Text( - "You", - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith( - color: Colors.white, - fontSize: 12, - ), - ), - const Padding( - padding: EdgeInsets.only(right: 12), - ), - Container( - width: 8.71, - height: 8.99, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withOpacity(0.75), - ), - ), - const Padding( - padding: EdgeInsets.only(right: 4), - ), - Text( - "Family", - style: Theme.of(context) - .textTheme - .bodyText1! - .copyWith( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ) - : Text( - "${convertBytesToReadableFormat(userDetails.getFamilyOrPersonalUsage())} used", - style: - Theme.of(context).textTheme.bodyText1!.copyWith( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/ui/settings/security_section_widget.dart b/lib/ui/settings/security_section_widget.dart index e2590e8f2..3cc4c049f 100644 --- a/lib/ui/settings/security_section_widget.dart +++ b/lib/ui/settings/security_section_widget.dart @@ -11,6 +11,7 @@ import 'package:photos/ente_theme_data.dart'; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/services/local_authentication_service.dart'; import 'package:photos/services/user_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/account/sessions_page.dart'; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; @@ -72,8 +73,8 @@ class _SecuritySectionWidgetState extends State { ), trailingSwitch: snapshot.hasData ? ToggleSwitchWidget( - value: snapshot.data, - onChanged: (value) async { + value: () => snapshot.data, + onChanged: () async { final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication( @@ -81,7 +82,7 @@ class _SecuritySectionWidgetState extends State { "Please authenticate to configure two-factor authentication", ); if (hasAuthenticated) { - if (value) { + if (!snapshot.data) { UserService.instance.setupTwoFactor(context); } else { _disableTwoFactor(); @@ -105,18 +106,15 @@ class _SecuritySectionWidgetState extends State { title: "Lockscreen", ), trailingSwitch: ToggleSwitchWidget( - value: _config.shouldShowLockScreen(), - onChanged: (value) async { - final hasAuthenticated = await LocalAuthenticationService.instance + value: () => _config.shouldShowLockScreen(), + onChanged: () async { + await LocalAuthenticationService.instance .requestLocalAuthForLockScreen( context, - value, + !_config.shouldShowLockScreen(), "Please authenticate to change lockscreen setting", "To enable lockscreen, please setup device passcode or screen lock in your system settings.", ); - if (hasAuthenticated) { - setState(() {}); - } }, ), ), @@ -130,81 +128,8 @@ class _SecuritySectionWidgetState extends State { title: "Hide from recents", ), trailingSwitch: ToggleSwitchWidget( - value: _config.shouldHideFromRecents(), - onChanged: (value) async { - if (value) { - final AlertDialog alert = AlertDialog( - title: const Text("Hide from recents?"), - content: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - "Hiding from the task switcher will prevent you from taking screenshots in this app.", - style: TextStyle( - height: 1.5, - ), - ), - Padding(padding: EdgeInsets.all(8)), - Text( - "Are you sure?", - style: TextStyle( - height: 1.5, - ), - ), - ], - ), - ), - actions: [ - TextButton( - child: Text( - "No", - style: TextStyle( - color: - Theme.of(context).colorScheme.defaultTextColor, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true) - .pop('dialog'); - }, - ), - TextButton( - child: Text( - "Yes", - style: TextStyle( - color: - Theme.of(context).colorScheme.defaultTextColor, - ), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true) - .pop('dialog'); - await _config.setShouldHideFromRecents(true); - await FlutterWindowManager.addFlags( - FlutterWindowManager.FLAG_SECURE, - ); - setState(() {}); - }, - ), - ], - ); - - showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - ); - } else { - await _config.setShouldHideFromRecents(false); - await FlutterWindowManager.clearFlags( - FlutterWindowManager.FLAG_SECURE, - ); - setState(() {}); - } - }, + value: () => _config.shouldHideFromRecents(), + onChanged: _hideFromRecentsOnChanged, ), ), sectionOptionSpacing, @@ -216,6 +141,7 @@ class _SecuritySectionWidgetState extends State { captionedTextWidget: const CaptionedTextWidget( title: "Active sessions", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -282,4 +208,74 @@ class _SecuritySectionWidgetState extends State { }, ); } + + Future _hideFromRecentsOnChanged() async { + if (!_config.shouldHideFromRecents()) { + final AlertDialog alert = AlertDialog( + title: const Text("Hide from recents?"), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + "Hiding from the task switcher will prevent you from taking screenshots in this app.", + style: TextStyle( + height: 1.5, + ), + ), + Padding(padding: EdgeInsets.all(8)), + Text( + "Are you sure?", + style: TextStyle( + height: 1.5, + ), + ), + ], + ), + ), + actions: [ + TextButton( + child: Text( + "No", + style: TextStyle( + color: Theme.of(context).colorScheme.defaultTextColor, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + TextButton( + child: Text( + "Yes", + style: TextStyle( + color: Theme.of(context).colorScheme.defaultTextColor, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + await _config.setShouldHideFromRecents(true); + await FlutterWindowManager.addFlags( + FlutterWindowManager.FLAG_SECURE, + ); + setState(() {}); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } else { + await _config.setShouldHideFromRecents(false); + await FlutterWindowManager.clearFlags( + FlutterWindowManager.FLAG_SECURE, + ); + } + } } diff --git a/lib/ui/settings/settings_title_bar_widget.dart b/lib/ui/settings/settings_title_bar_widget.dart index 873d9c5ef..3c2e0482e 100644 --- a/lib/ui/settings/settings_title_bar_widget.dart +++ b/lib/ui/settings/settings_title_bar_widget.dart @@ -37,15 +37,13 @@ class SettingsTitleBarWidget extends StatelessWidget { ' on tree', ); throw Error(); - } - if (snapshot.hasData) { + } else if (snapshot.hasData) { final userDetails = snapshot.data as UserDetails; return Text( "${NumberFormat().format(userDetails.fileCount)} memories", style: getEnteTextTheme(context).largeBold, ); - } - if (snapshot.hasError) { + } else if (snapshot.hasError) { logger.severe('failed to load user details'); return const EnteLoadingWidget(); } else { diff --git a/lib/ui/settings/social_section_widget.dart b/lib/ui/settings/social_section_widget.dart index 54cfc3b29..7aa86fd8d 100644 --- a/lib/ui/settings/social_section_widget.dart +++ b/lib/ui/settings/social_section_widget.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:photos/services/update_service.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget.dart'; @@ -61,6 +62,7 @@ class SocialsMenuItemWidget extends StatelessWidget { captionedTextWidget: CaptionedTextWidget( title: text, ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () { diff --git a/lib/ui/settings/storage_card_widget.dart b/lib/ui/settings/storage_card_widget.dart new file mode 100644 index 000000000..ba832bdbb --- /dev/null +++ b/lib/ui/settings/storage_card_widget.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/models/user_details.dart'; +import 'package:photos/states/user_details_state.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/common/loading_widget.dart'; +// ignore: import_of_legacy_library_into_null_safe +import 'package:photos/ui/payment/subscription.dart'; +import 'package:photos/ui/settings/storage_error_widget.dart'; +import 'package:photos/ui/settings/storage_progress_widget.dart'; +import 'package:photos/utils/data_util.dart'; + +class StorageCardWidget extends StatefulWidget { + const StorageCardWidget({Key? key}) : super(key: key); + + @override + State createState() => _StorageCardWidgetState(); +} + +class _StorageCardWidgetState extends State { + late Image _background; + final _logger = Logger((_StorageCardWidgetState).toString()); + final ValueNotifier _isStorageCardPressed = ValueNotifier(false); + + @override + void initState() { + super.initState(); + _background = const Image( + image: AssetImage("assets/storage_card_background.png"), + fit: BoxFit.fill, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // precache background image to avoid flicker + // https://stackoverflow.com/questions/51343735/flutter-image-preload + precacheImage(_background.image, context); + } + + @override + Widget build(BuildContext context) { + final inheritedUserDetails = InheritedUserDetails.of(context); + + if (inheritedUserDetails == null) { + _logger.severe( + (InheritedUserDetails).toString() + 'is null', + ); + throw Error(); + } else { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return getSubscriptionPage(); + }, + ), + ); + }, + onTapDown: (details) => _isStorageCardPressed.value = true, + onTapCancel: () => _isStorageCardPressed.value = false, + onTapUp: (details) => _isStorageCardPressed.value = false, + child: containerForUserDetails(inheritedUserDetails), + ); + } + } + + Widget containerForUserDetails( + InheritedUserDetails inheritedUserDetails, + ) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 350), + child: AspectRatio( + aspectRatio: 2 / 1, + child: Stack( + children: [ + _background, + FutureBuilder( + future: inheritedUserDetails.userDetails, + builder: (context, snapshot) { + if (snapshot.hasData) { + return userDetails(snapshot.data as UserDetails); + } + if (snapshot.hasError) { + _logger.severe( + 'failed to load user details', + snapshot.error, + ); + return const StorageErrorWidget(); + } + return const EnteLoadingWidget(color: strokeBaseDark); + }, + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: ValueListenableBuilder( + builder: (BuildContext context, bool value, Widget? child) { + return Icon( + Icons.chevron_right_outlined, + color: value ? strokeMutedDark : strokeBaseDark, + ); + }, + valueListenable: _isStorageCardPressed, + ), + ), + ), + ], + ), + ), + ); + } + + Widget userDetails(UserDetails userDetails) { + const hundredMBinBytes = 107374182; + + final isMobileScreenSmall = MediaQuery.of(context).size.width <= 365; + final freeSpaceInBytes = userDetails.getFreeStorage(); + final shouldShowFreeSpaceInMBs = freeSpaceInBytes < hundredMBinBytes; + + final usedSpaceInGB = roundBytesUsedToGBs( + userDetails.getFamilyOrPersonalUsage(), + userDetails.getFreeStorage(), + ); + final totalStorageInGB = + convertBytesToGBs(userDetails.getTotalStorage()).truncate(); + + return Padding( + padding: EdgeInsets.fromLTRB( + 16, + 20, + 16, + isMobileScreenSmall ? 12 : 20, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isMobileScreenSmall ? "Used space" : "Storage", + style: getEnteTextTheme(context) + .small + .copyWith(color: textMutedDark), + ), + const SizedBox(height: 2), + RichText( + overflow: TextOverflow.ellipsis, + maxLines: 1, + text: TextSpan( + style: getEnteTextTheme(context) + .h3Bold + .copyWith(color: textBaseDark), + children: [ + TextSpan(text: usedSpaceInGB.toString()), + TextSpan(text: isMobileScreenSmall ? "/" : " GB of "), + TextSpan(text: totalStorageInGB.toString() + " GB"), + TextSpan(text: isMobileScreenSmall ? "" : " used"), + ], + ), + ), + ], + ), + ), + Column( + children: [ + Stack( + children: [ + const StorageProgressWidget( + color: + Color.fromRGBO(255, 255, 255, 0.2), //hardcoded in figma + fractionOfStorage: 1, + ), + userDetails.isPartOfFamily() + ? StorageProgressWidget( + color: strokeBaseDark, + fractionOfStorage: + ((userDetails.getFamilyOrPersonalUsage()) / + userDetails.getTotalStorage()), + ) + : const SizedBox.shrink(), + StorageProgressWidget( + color: userDetails.isPartOfFamily() + ? getEnteColorScheme(context).primary300 + : strokeBaseDark, + fractionOfStorage: + (userDetails.usage / userDetails.getTotalStorage()), + ) + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + userDetails.isPartOfFamily() + ? Row( + children: [ + Container( + width: 8.71, + height: 8.99, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: getEnteColorScheme(context).primary300, + ), + ), + const SizedBox(width: 4), + Text( + "You", + style: getEnteTextTheme(context) + .miniBold + .copyWith(color: textBaseDark), + ), + const SizedBox(width: 12), + Container( + width: 8.71, + height: 8.99, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: textBaseDark, + ), + ), + const SizedBox(width: 4), + Text( + "Family", + style: getEnteTextTheme(context) + .miniBold + .copyWith(color: textBaseDark), + ), + ], + ) + : const SizedBox.shrink(), + RichText( + text: TextSpan( + style: getEnteTextTheme(context) + .mini + .copyWith(color: textFaintDark), + children: [ + TextSpan( + text: + "${shouldShowFreeSpaceInMBs ? convertBytesToMBs(freeSpaceInBytes) : _roundedFreeSpace(totalStorageInGB, usedSpaceInGB)}", + ), + TextSpan( + text: shouldShowFreeSpaceInMBs + ? " MB free" + : " GB free", + ) + ], + ), + ), + ], + ), + ], + ) + ], + ), + ); + } + + num _roundedFreeSpace(num totalStorageInGB, num usedSpaceInGB) { + int fractionDigits; + //subtracting usedSpace from totalStorage in GB instead of converting from bytes so that free space and used space adds up in the UI + final freeSpace = totalStorageInGB - usedSpaceInGB; + //show one decimal place if free space is less than 10GB + if (freeSpace < 10) { + fractionDigits = 1; + } else { + fractionDigits = 0; + } + //omit decimal if decimal is 0 + if (fractionDigits == 1 && freeSpace.remainder(1) == 0) { + fractionDigits = 0; + } + return num.parse(freeSpace.toStringAsFixed(fractionDigits)); + } +} diff --git a/lib/ui/settings/storage_error_widget.dart b/lib/ui/settings/storage_error_widget.dart new file mode 100644 index 000000000..605bfc7fb --- /dev/null +++ b/lib/ui/settings/storage_error_widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; + +class StorageErrorWidget extends StatelessWidget { + const StorageErrorWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Icon( + Icons.error_outline_outlined, + color: strokeBaseDark, + ), + const SizedBox(height: 8), + Text( + "Your storage details could not be fetched", + style: getEnteTextTheme(context).small.copyWith( + color: textMutedDark, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/settings/storage_progress_widget.dart b/lib/ui/settings/storage_progress_widget.dart new file mode 100644 index 000000000..44fdb9666 --- /dev/null +++ b/lib/ui/settings/storage_progress_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class StorageProgressWidget extends StatelessWidget { + final Color color; + final double fractionOfStorage; + const StorageProgressWidget({ + required this.color, + required this.fractionOfStorage, + super.key, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constrains) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: color, + ), + width: constrains.maxWidth * fractionOfStorage, + height: 4, + ); + }, + ); + } +} diff --git a/lib/ui/settings/support_section_widget.dart b/lib/ui/settings/support_section_widget.dart index 9fa9c323d..7f20dca5e 100644 --- a/lib/ui/settings/support_section_widget.dart +++ b/lib/ui/settings/support_section_widget.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; @@ -34,6 +35,7 @@ class SupportSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Email", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { @@ -45,6 +47,7 @@ class SupportSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Roadmap", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () { @@ -67,6 +70,7 @@ class SupportSectionWidget extends StatelessWidget { captionedTextWidget: const CaptionedTextWidget( title: "Report a bug", ), + pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { diff --git a/lib/ui/settings/theme_switch_widget.dart b/lib/ui/settings/theme_switch_widget.dart index dff1d0889..8bfa21528 100644 --- a/lib/ui/settings/theme_switch_widget.dart +++ b/lib/ui/settings/theme_switch_widget.dart @@ -4,6 +4,7 @@ import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:photos/ente_theme_data.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget.dart'; @@ -69,7 +70,8 @@ class _ThemeSwitchWidgetState extends State { title: toBeginningOfSentenceCase(themeMode.name), textStyle: Theme.of(context).colorScheme.enteTheme.textTheme.body, ), - isHeaderOfExpansion: false, + pressedColor: getEnteColorScheme(context).fillFaint, + isExpandable: false, trailingIcon: currentThemeMode == themeMode ? Icons.check : null, onTap: () async { AdaptiveTheme.of(context).setThemeMode(themeMode); diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 4887f01bb..eda28c035 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -14,10 +14,10 @@ import 'package:photos/ui/settings/app_version_widget.dart'; import 'package:photos/ui/settings/backup_section_widget.dart'; import 'package:photos/ui/settings/danger_section_widget.dart'; import 'package:photos/ui/settings/debug_section_widget.dart'; -import 'package:photos/ui/settings/details_section_widget.dart'; import 'package:photos/ui/settings/security_section_widget.dart'; import 'package:photos/ui/settings/settings_title_bar_widget.dart'; import 'package:photos/ui/settings/social_section_widget.dart'; +import 'package:photos/ui/settings/storage_card_widget.dart'; import 'package:photos/ui/settings/support_section_widget.dart'; import 'package:photos/ui/settings/theme_switch_widget.dart'; @@ -42,6 +42,7 @@ class SettingsPage extends StatelessWidget { final List contents = []; contents.add( Container( + constraints: const BoxConstraints(maxWidth: 350), padding: const EdgeInsets.symmetric(horizontal: 8), child: Align( alignment: Alignment.centerLeft, @@ -65,7 +66,7 @@ class SettingsPage extends StatelessWidget { contents.add(const SizedBox(height: 8)); if (hasLoggedIn) { contents.addAll([ - const DetailsSectionWidget(), + const StorageCardWidget(), const SizedBox(height: 12), const BackupSectionWidget(), sectionSpacing, diff --git a/lib/ui/shared_collections_gallery.dart b/lib/ui/shared_collections_gallery.dart index 817db0403..64e29d4fd 100644 --- a/lib/ui/shared_collections_gallery.dart +++ b/lib/ui/shared_collections_gallery.dart @@ -126,7 +126,7 @@ class _SharedCollectionGalleryState extends State child: Column( children: [ const SizedBox(height: 12), - const SectionTitle("Shared with me"), + const SectionTitle(title: "Shared with me"), const SizedBox(height: 12), collections.incoming.isNotEmpty ? Padding( @@ -150,7 +150,7 @@ class _SharedCollectionGalleryState extends State ), ) : _getIncomingCollectionEmptyState(), - const SectionTitle("Shared by me"), + const SectionTitle(title: "Shared by me"), const SizedBox(height: 12), collections.outgoing.isNotEmpty ? ListView.builder( diff --git a/lib/ui/tools/editor/image_editor_page.dart b/lib/ui/tools/editor/image_editor_page.dart index 135869625..0cd094ad3 100644 --- a/lib/ui/tools/editor/image_editor_page.dart +++ b/lib/ui/tools/editor/image_editor_page.dart @@ -1,6 +1,7 @@ // @dart=2.9 import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:extended_image/extended_image.dart'; @@ -370,13 +371,19 @@ class _ImageEditorPageState extends State { existingFiles[0].creationTime, )) .files; + // the index could be -1 if the files fetched doesn't contain the newly + // edited files + final selectionIndex = + files.indexWhere((file) => file.generatedID == newFile.generatedID); + if (selectionIndex == -1) { + files.add(newFile); + } replacePage( context, DetailPage( widget.detailPageConfig.copyWith( files: files, - selectedIndex: files - .indexWhere((file) => file.generatedID == newFile.generatedID), + selectedIndex: min(selectionIndex, files.length - 1), ), ), ); diff --git a/lib/ui/viewer/file/collections_list_of_file_widget.dart b/lib/ui/viewer/file/collections_list_of_file_widget.dart index ee2f0a8e0..1f0d5bfb4 100644 --- a/lib/ui/viewer/file/collections_list_of_file_widget.dart +++ b/lib/ui/viewer/file/collections_list_of_file_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_items.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/ui/common/loading_widget.dart'; @@ -11,6 +12,7 @@ import 'package:photos/utils/navigation_util.dart'; class CollectionsListOfFileWidget extends StatelessWidget { final Future> allCollectionIDsOfFile; + const CollectionsListOfFileWidget(this.allCollectionIDsOfFile, {Key key}) : super(key: key); @@ -21,19 +23,23 @@ class CollectionsListOfFileWidget extends StatelessWidget { builder: (context, snapshot) { if (snapshot.hasData) { final Set collectionIDs = snapshot.data; - final collections = []; + final collections = []; for (var collectionID in collectionIDs) { - collections.add( - CollectionsService.instance.getCollectionByID(collectionID), - ); + final c = + CollectionsService.instance.getCollectionByID(collectionID); + collections.add(c); } return ListView.builder( itemCount: collections.length, scrollDirection: Axis.horizontal, itemBuilder: (context, index) { + final bool isHidden = collections[index].isHidden(); return FileInfoCollectionWidget( - name: collections[index].name, + name: isHidden ? 'Hidden' : collections[index].name, onTap: () { + if (isHidden) { + return; + } routeToPage( context, CollectionPage( diff --git a/lib/ui/viewer/file/fading_app_bar.dart b/lib/ui/viewer/file/fading_app_bar.dart index 035098893..b00af9628 100644 --- a/lib/ui/viewer/file/fading_app_bar.dart +++ b/lib/ui/viewer/file/fading_app_bar.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:like_button/like_button.dart'; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension.dart'; +import 'package:page_transition/page_transition.dart'; import 'package:path/path.dart' as file_path; import 'package:photo_manager/photo_manager.dart'; import 'package:photos/core/event_bus.dart'; @@ -16,11 +17,15 @@ import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/ignored_file.dart'; +import 'package:photos/models/selected_files.dart'; import 'package:photos/models/trash_file.dart'; +import 'package:photos/services/collections_service.dart'; import 'package:photos/services/favorites_service.dart'; +import 'package:photos/services/hidden_service.dart'; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/ui/common/progress_dialog.dart'; +import 'package:photos/ui/create_collection_page.dart'; import 'package:photos/ui/viewer/file/custom_app_bar.dart'; import 'package:photos/utils/delete_file_util.dart'; import 'package:photos/utils/dialog_util.dart'; @@ -99,11 +104,21 @@ class FadingAppBarState extends State { AppBar _buildAppBar() { debugPrint("building app bar"); + final List actions = []; final isTrashedFile = widget.file is TrashFile; final shouldShowActions = widget.shouldShowActions && !isTrashedFile; + final bool isOwnedByUser = + widget.file.ownerID == null || widget.file.ownerID == widget.userID; + bool isFileHidden = false; + if (isOwnedByUser && widget.file.uploadedFileID != null) { + isFileHidden = CollectionsService.instance + .getCollectionByID(widget.file.collectionID) + ?.isHidden() ?? + false; + } // only show fav option for files owned by the user - if (widget.file.ownerID == null || widget.file.ownerID == widget.userID) { + if (isOwnedByUser && !isFileHidden) { actions.add(_getFavoriteButton()); } actions.add( @@ -132,8 +147,7 @@ class FadingAppBarState extends State { ); } // options for files owned by the user - if (widget.file.ownerID == null || - widget.file.ownerID == widget.userID) { + if (isOwnedByUser) { items.add( PopupMenuItem( value: 2, @@ -169,12 +183,51 @@ class FadingAppBarState extends State { const Padding( padding: EdgeInsets.all(8), ), - const Text("Use as"), + const Text("Set as"), ], ), ), ); } + if (isOwnedByUser) { + if (!isFileHidden) { + items.add( + PopupMenuItem( + value: 4, + child: Row( + children: [ + Icon( + Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + ), + const Padding( + padding: EdgeInsets.all(8), + ), + const Text("Hide"), + ], + ), + ), + ); + } else { + items.add( + PopupMenuItem( + value: 5, + child: Row( + children: [ + Icon( + Icons.visibility, + color: Theme.of(context).iconTheme.color, + ), + const Padding( + padding: EdgeInsets.all(8), + ), + const Text("Unhide"), + ], + ), + ), + ); + } + } return items; }, onSelected: (value) { @@ -184,6 +237,10 @@ class FadingAppBarState extends State { _showDeleteSheet(widget.file); } else if (value == 3) { _setAs(widget.file); + } else if (value == 4) { + _handleHideRequest(context); + } else if (value == 5) { + _handleUnHideRequest(context); } }, ), @@ -197,6 +254,38 @@ class FadingAppBarState extends State { ); } + Future _handleHideRequest(BuildContext context) async { + try { + final hideResult = + await CollectionsService.instance.hideFiles(context, [widget.file]); + + if (hideResult) { + // delay to avoid black screen + await Future.delayed(const Duration(milliseconds: 300)); + Navigator.of(context).pop(); + } + } catch (e, s) { + _logger.severe("failed to update file visibility", e, s); + await showGenericErrorDialog(context); + } + } + + Future _handleUnHideRequest(BuildContext context) async { + final s = SelectedFiles(); + s.files.add(widget.file); + Navigator.push( + context, + PageTransition( + type: PageTransitionType.bottomToTop, + child: CreateCollectionPage( + s, + null, + actionType: CollectionActionType.unHide, + ), + ), + ); + } + Widget _getFavoriteButton() { return FutureBuilder( future: FavoritesService.instance.isFavorite(widget.file), @@ -326,55 +415,97 @@ class FadingAppBarState extends State { Future _download(File file) async { final dialog = createProgressDialog(context, "Downloading..."); await dialog.show(); - final FileType type = file.fileType; - // save and track image for livePhoto/image and video for FileType.video - final io.File fileToSave = await getFile(file); - final savedAsset = type == FileType.video - ? (await PhotoManager.editor.saveVideo(fileToSave, title: file.title)) - : (await PhotoManager.editor - .saveImageWithPath(fileToSave.path, title: file.title)); - // immediately track assetID to avoid duplicate upload - await LocalSyncService.instance.trackDownloadedFile(savedAsset.id); - file.localID = savedAsset.id; - await FilesDB.instance.insert(file); + try { + final FileType type = file.fileType; + final bool downloadLivePhotoOnDroid = + type == FileType.livePhoto && Platform.isAndroid; + AssetEntity savedAsset; + final io.File fileToSave = await getFile(file); + if (type == FileType.image) { + savedAsset = await PhotoManager.editor + .saveImageWithPath(fileToSave.path, title: file.title); + } else if (type == FileType.video) { + savedAsset = + await PhotoManager.editor.saveVideo(fileToSave, title: file.title); + } else if (type == FileType.livePhoto) { + final io.File liveVideoFile = + await getFileFromServer(file, liveVideo: true); + if (liveVideoFile == null) { + throw AssertionError("Live video can not be null"); + } + if (downloadLivePhotoOnDroid) { + await _saveLivePhotoOnDroid(fileToSave, liveVideoFile, file); + } else { + savedAsset = await PhotoManager.editor.darwin.saveLivePhoto( + imageFile: fileToSave, + videoFile: liveVideoFile, + title: file.title, + ); + } + } - if (type == FileType.livePhoto) { - final io.File liveVideo = await getFileFromServer(file, liveVideo: true); - if (liveVideo == null) { - _logger.warning("Failed to find live video" + file.tag); - } else { - final videoTitle = file_path.basenameWithoutExtension(file.title) + - file_path.extension(liveVideo.path); - final savedAsset = (await PhotoManager.editor.saveVideo( - liveVideo, - title: videoTitle, - )); + if (savedAsset != null) { + // immediately track assetID to avoid duplicate upload + await LocalSyncService.instance.trackDownloadedFile(savedAsset.id); final ignoreVideoFile = IgnoredFile( savedAsset.id, - savedAsset.title ?? videoTitle, + savedAsset.title ?? "", savedAsset.relativePath ?? 'remoteDownload', "remoteDownload", ); debugPrint("IgnoreFile for auto-upload ${ignoreVideoFile.toString()}"); await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]); + file.localID = savedAsset.id; + await FilesDB.instance.insert(file); + Bus.instance.fire(LocalPhotosUpdatedEvent([file])); + } else if (!downloadLivePhotoOnDroid && savedAsset == null) { + _logger.severe('Failed to save assert of type $type'); } - } - - Bus.instance.fire(LocalPhotosUpdatedEvent([file])); - await dialog.hide(); - if (file.fileType == FileType.livePhoto) { - showToast(context, "Photo and video saved to gallery"); - } else { showToast(context, "File saved to gallery"); + await dialog.hide(); + } catch (e) { + _logger.warning("Failed to save file", e); + await dialog.hide(); + showGenericErrorDialog(context); } } + Future _saveLivePhotoOnDroid( + io.File image, + io.File video, + File enteFile, + ) async { + debugPrint("Downloading LivePhoto on Droid"); + AssetEntity savedAsset = await PhotoManager.editor + .saveImageWithPath(image.path, title: enteFile.title); + IgnoredFile ignoreVideoFile = IgnoredFile( + savedAsset.id, + savedAsset.title ?? '', + savedAsset.relativePath ?? 'remoteDownload', + "remoteDownload", + ); + await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]); + final videoTitle = file_path.basenameWithoutExtension(enteFile.title) + + file_path.extension(video.path); + savedAsset = (await PhotoManager.editor.saveVideo( + video, + title: videoTitle, + )); + ignoreVideoFile = IgnoredFile( + savedAsset.id, + savedAsset.title ?? videoTitle, + savedAsset.relativePath ?? 'remoteDownload', + "remoteDownload", + ); + await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]); + } + Future _setAs(File file) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final io.File fileToSave = await getFile(file); - var m = MediaExtension(); + final m = MediaExtension(); final bool result = await m.setAs("file://${fileToSave.path}", "image/*"); if (result == false) { showShortToast(context, "Something went wrong"); diff --git a/lib/ui/viewer/file/fading_bottom_bar.dart b/lib/ui/viewer/file/fading_bottom_bar.dart index 61dd57ff2..2b8a76b29 100644 --- a/lib/ui/viewer/file/fading_bottom_bar.dart +++ b/lib/ui/viewer/file/fading_bottom_bar.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:page_transition/page_transition.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/models/file.dart'; @@ -11,6 +12,9 @@ import 'package:photos/models/file_type.dart'; import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/models/trash_file.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/create_collection_page.dart'; import 'package:photos/ui/viewer/file/file_info_widget.dart'; import 'package:photos/utils/delete_file_util.dart'; @@ -72,8 +76,13 @@ class FadingBottomBarState extends State { Platform.isAndroid ? Icons.info_outline : CupertinoIcons.info, color: Colors.white, ), - onPressed: () { - _displayInfo(widget.file); + onPressed: () async { + await _displayInfo(widget.file); + safeRefresh(); //to instantly show the new caption if keypad is closed after pressing 'done' - here the caption will be updated before the bottom sheet is closed + await Future.delayed( + const Duration(milliseconds: 500), + ); //Waiting for some time till the caption gets updated in db if the user closes the bottom sheet without pressing 'done' + safeRefresh(); }, ), ), @@ -82,6 +91,15 @@ class FadingBottomBarState extends State { if (widget.file is TrashFile) { _addTrashOptions(children); } + final bool isUploadedByUser = widget.file.uploadedFileID != null && + widget.file.ownerID == Configuration.instance.getUserID(); + bool isFileHidden = false; + if (isUploadedByUser) { + isFileHidden = CollectionsService.instance + .getCollectionByID(widget.file.collectionID) + ?.isHidden() ?? + false; + } if (!widget.showOnlyInfoButton && widget.file is! TrashFile) { if (widget.file.fileType == FileType.image || widget.file.fileType == FileType.livePhoto) { @@ -103,20 +121,17 @@ class FadingBottomBarState extends State { ), ); } - if (widget.file.uploadedFileID != null && - widget.file.ownerID == Configuration.instance.getUserID()) { + if (isUploadedByUser && !isFileHidden) { final bool isArchived = widget.file.magicMetadata.visibility == visibilityArchive; children.add( Tooltip( - message: isArchived ? "Unhide" : "Hide", + message: isArchived ? "Unarchive" : "Archive", child: Padding( padding: const EdgeInsets.only(top: 12, bottom: 12), child: IconButton( icon: Icon( - isArchived - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, + isArchived ? Icons.unarchive : Icons.archive_outlined, color: Colors.white, ), onPressed: () async { @@ -176,9 +191,31 @@ class FadingBottomBarState extends State { ), child: Padding( padding: EdgeInsets.only(bottom: safeAreaBottomPadding), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: children, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.file.caption?.isNotEmpty ?? false + ? Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 28, + 16, + 12, + ), + child: Text( + widget.file.caption, + style: getEnteTextTheme(context) + .small + .copyWith(color: textBaseDark), + textAlign: TextAlign.center, + ), + ) + : const SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children, + ), + ], ), ), ), @@ -242,11 +279,19 @@ class FadingBottomBarState extends State { } Future _displayInfo(File file) async { - return showModalBottomSheet( + final colorScheme = getEnteColorScheme(context); + return showBarModalBottomSheet( + topControl: const SizedBox.shrink(), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), + backgroundColor: colorScheme.backgroundBase, + barrierColor: backdropFaintDark, context: context, - isScrollControlled: true, builder: (BuildContext context) { - return FileInfoWidget(file); + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: FileInfoWidget(file), + ); }, ); } diff --git a/lib/ui/viewer/file/file_caption_widget.dart b/lib/ui/viewer/file/file_caption_widget.dart new file mode 100644 index 000000000..026281748 --- /dev/null +++ b/lib/ui/viewer/file/file_caption_widget.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:photos/models/file.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/utils/magic_util.dart'; + +class FileCaptionWidget extends StatefulWidget { + final File file; + const FileCaptionWidget({required this.file, super.key}); + + @override + State createState() => _FileCaptionWidgetState(); +} + +class _FileCaptionWidgetState extends State { + int maxLength = 280; + int currentLength = 0; + final _textController = TextEditingController(); + final _focusNode = FocusNode(); + String? editedCaption; + String? hintText = "Add a description..."; + + @override + void initState() { + _focusNode.addListener(() { + final caption = widget.file.caption; + if (_focusNode.hasFocus && caption != null) { + _textController.text = caption; + editedCaption = caption; + } + }); + editedCaption = widget.file.caption; + if (editedCaption != null && editedCaption!.isNotEmpty) { + hintText = editedCaption; + } + super.initState(); + } + + @override + void dispose() { + if (editedCaption != null) { + editFileCaption(null, widget.file, editedCaption); + } + _textController.dispose(); + _focusNode.removeListener(() {}); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return TextField( + onEditingComplete: () async { + if (editedCaption != null) { + await editFileCaption(context, widget.file, editedCaption); + if (mounted) { + setState(() {}); + } + } + _focusNode.unfocus(); + }, + controller: _textController, + focusNode: _focusNode, + decoration: InputDecoration( + counterStyle: textTheme.mini.copyWith(color: colorScheme.textMuted), + counterText: currentLength > 99 + ? currentLength.toString() + " / " + maxLength.toString() + : "", + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(2), + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(2), + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + filled: true, + fillColor: colorScheme.fillFaint, + hintText: hintText, + hintStyle: getEnteTextTheme(context) + .small + .copyWith(color: colorScheme.textMuted), + ), + style: getEnteTextTheme(context).small, + cursorWidth: 1.5, + maxLength: maxLength, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.text, + onChanged: (value) { + setState(() { + hintText = "Add a description..."; + currentLength = value.length; + editedCaption = value; + }); + }, + ); + } +} diff --git a/lib/ui/viewer/file/file_info_widget.dart b/lib/ui/viewer/file/file_info_widget.dart index 69edf56c5..dded6d08e 100644 --- a/lib/ui/viewer/file/file_info_widget.dart +++ b/lib/ui/viewer/file/file_info_widget.dart @@ -9,10 +9,13 @@ import 'package:photos/db/files_db.dart'; import "package:photos/ente_theme_data.dart"; import "package:photos/models/file.dart"; import "package:photos/models/file_type.dart"; -import 'package:photos/ui/common/DividerWithPadding.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; import 'package:photos/ui/viewer/file/collections_list_of_file_widget.dart'; import 'package:photos/ui/viewer/file/device_folders_list_of_file_widget.dart'; -import 'package:photos/ui/viewer/file/raw_exif_button.dart'; +import 'package:photos/ui/viewer/file/file_caption_widget.dart'; +import 'package:photos/ui/viewer/file/raw_exif_list_tile_widget.dart'; import "package:photos/utils/date_time_util.dart"; import "package:photos/utils/exif_util.dart"; import "package:photos/utils/file_util.dart"; @@ -51,9 +54,11 @@ class _FileInfoWidgetState extends State { widget.file.fileType == FileType.livePhoto; if (_isImage) { getExif(widget.file).then((exif) { - setState(() { - _exif = exif; - }); + if (mounted) { + setState(() { + _exif = exif; + }); + } }); } super.initState(); @@ -88,9 +93,17 @@ class _FileInfoWidgetState extends State { final bool showDimension = _exifData["resolution"] != null && _exifData["megaPixels"] != null; final listTiles = [ + widget.file.uploadedFileID == null || + Configuration.instance.getUserID() != file.ownerID + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: FileCaptionWidget(file: widget.file), + ), ListTile( + horizontalTitleGap: 2, leading: const Padding( - padding: EdgeInsets.only(top: 8, left: 6), + padding: EdgeInsets.only(top: 8), child: Icon(Icons.calendar_today_rounded), ), title: Text( @@ -119,17 +132,17 @@ class _FileInfoWidgetState extends State { ) : const SizedBox.shrink(), ), - const DividerWithPadding(left: 70, right: 20), ListTile( + horizontalTitleGap: 2, leading: _isImage ? const Padding( - padding: EdgeInsets.only(top: 8, left: 6), + padding: EdgeInsets.only(top: 8), child: Icon( Icons.image, ), ) : const Padding( - padding: EdgeInsets.only(top: 8, left: 6), + padding: EdgeInsets.only(top: 8), child: Icon( Icons.video_camera_back, size: 27, @@ -167,13 +180,10 @@ class _FileInfoWidgetState extends State { icon: const Icon(Icons.edit), ), ), - const DividerWithPadding(left: 70, right: 20), showExifListTile ? ListTile( - leading: const Padding( - padding: EdgeInsets.only(left: 6), - child: Icon(Icons.camera_rounded), - ), + horizontalTitleGap: 2, + leading: const Icon(Icons.camera_rounded), title: Text(_exifData["takenOnDevice"] ?? "--"), subtitle: Row( children: [ @@ -205,27 +215,22 @@ class _FileInfoWidgetState extends State { ], ), ) - : const SizedBox.shrink(), - showExifListTile - ? const DividerWithPadding(left: 70, right: 20) - : const SizedBox.shrink(), + : null, SizedBox( height: 62, child: ListTile( - leading: const Padding( - padding: EdgeInsets.only(left: 6), - child: Icon(Icons.folder_outlined), - ), + horizontalTitleGap: 0, + leading: const Icon(Icons.folder_outlined), title: fileIsBackedup ? CollectionsListOfFileWidget(allCollectionIDsOfFile) : DeviceFoldersListOfFileWidget(allDeviceFoldersOfFile), ), ), - const DividerWithPadding(left: 70, right: 20), (file.uploadedFileID != null && file.updationTime != null) ? ListTile( + horizontalTitleGap: 2, leading: const Padding( - padding: EdgeInsets.only(top: 8, left: 6), + padding: EdgeInsets.only(top: 8), child: Icon(Icons.cloud_upload_outlined), ), title: Text( @@ -245,48 +250,53 @@ class _FileInfoWidgetState extends State { ), ), ) - : const SizedBox.shrink(), - _isImage - ? Padding( - padding: const EdgeInsets.fromLTRB(0, 24, 0, 16), - child: SafeArea( - child: RawExifButton(_exif, widget.file), - ), - ) - : const SizedBox( - height: 12, - ) + : null, + _isImage ? RawExifListTileWidget(_exif, widget.file) : null, ]; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: const Icon( - Icons.close, + listTiles.removeWhere( + (element) => element == null, + ); + + return SafeArea( + top: false, + child: Scrollbar( + thickness: 4, + radius: const Radius.circular(2), + thumbVisibility: true, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + TitleBarWidget( + isFlexibleSpaceDisabled: true, + title: "Details", + isOnTopOfScreen: false, + leading: IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.primary, + onTap: () => Navigator.pop(context), ), ), - const SizedBox(width: 6), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - "Details", - style: Theme.of(context).textTheme.bodyText1, + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index.isOdd) { + return index == 1 + ? const SizedBox.shrink() + : const DividerWidget(dividerType: DividerType.menu); + } else { + return listTiles[index ~/ 2]; + } + }, + childCount: (listTiles.length * 2) - 1, ), - ), + ) ], ), ), - ...listTiles - ], + ), ); } diff --git a/lib/ui/viewer/file/raw_exif_button.dart b/lib/ui/viewer/file/raw_exif_button.dart deleted file mode 100644 index a34414c46..000000000 --- a/lib/ui/viewer/file/raw_exif_button.dart +++ /dev/null @@ -1,100 +0,0 @@ -// @dart=2.9 - -import 'package:exif/exif.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; -import "package:photos/models/file.dart"; -import 'package:photos/ui/viewer/file/exif_info_dialog.dart'; -import 'package:photos/utils/toast_util.dart'; - -enum Status { - loading, - exifIsAvailable, - noExif, -} - -class RawExifButton extends StatelessWidget { - final File file; - final Map exif; - const RawExifButton(this.exif, this.file, {Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - Status exifStatus = Status.loading; - if (exif == null) { - exifStatus = Status.loading; - } else if (exif.isNotEmpty) { - exifStatus = Status.exifIsAvailable; - } else { - exifStatus = Status.noExif; - } - return GestureDetector( - onTap: - exifStatus == Status.loading || exifStatus == Status.exifIsAvailable - ? () { - showDialog( - context: context, - builder: (BuildContext context) { - return ExifInfoDialog(file); - }, - barrierColor: Colors.black87, - ); - } - : exifStatus == Status.noExif - ? () { - showShortToast(context, "This image has no exif data"); - } - : null, - child: Container( - height: 40, - width: 140, - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .inverseBackgroundColor - .withOpacity(0.12), - borderRadius: const BorderRadius.all( - Radius.circular(20), - ), - ), - child: Center( - child: exifStatus == Status.loading - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - CupertinoActivityIndicator( - radius: 8, - ), - SizedBox( - width: 8, - ), - Text('EXIF') - ], - ) - : exifStatus == Status.exifIsAvailable - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.feed_outlined), - SizedBox( - width: 8, - ), - Text('Raw EXIF'), - ], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.feed_outlined), - SizedBox( - width: 8, - ), - Text('No EXIF'), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/viewer/file/raw_exif_list_tile_widget.dart b/lib/ui/viewer/file/raw_exif_list_tile_widget.dart new file mode 100644 index 000000000..40afb57ec --- /dev/null +++ b/lib/ui/viewer/file/raw_exif_list_tile_widget.dart @@ -0,0 +1,71 @@ +// @dart=2.9 + +import 'package:exif/exif.dart'; +import 'package:flutter/material.dart'; +import 'package:photos/ente_theme_data.dart'; +import "package:photos/models/file.dart"; +import 'package:photos/ui/viewer/file/exif_info_dialog.dart'; +import 'package:photos/utils/toast_util.dart'; + +enum Status { + loading, + exifIsAvailable, + noExif, +} + +class RawExifListTileWidget extends StatelessWidget { + final File file; + final Map exif; + const RawExifListTileWidget(this.exif, this.file, {Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + Status exifStatus = Status.loading; + if (exif == null) { + exifStatus = Status.loading; + } else if (exif.isNotEmpty) { + exifStatus = Status.exifIsAvailable; + } else { + exifStatus = Status.noExif; + } + return GestureDetector( + onTap: exifStatus == Status.exifIsAvailable + ? () { + showDialog( + context: context, + builder: (BuildContext context) { + return ExifInfoDialog(file); + }, + barrierColor: Colors.black87, + ); + } + : exifStatus == Status.noExif + ? () { + showShortToast(context, "This image has no exif data"); + } + : null, + child: ListTile( + horizontalTitleGap: 2, + leading: const Padding( + padding: EdgeInsets.only(top: 8), + child: Icon(Icons.feed_outlined), + ), + title: const Text("EXIF"), + subtitle: Text( + exifStatus == Status.loading + ? "Loading EXIF data.." + : exifStatus == Status.exifIsAvailable + ? "View all EXIF data" + : "No EXIF data", + style: Theme.of(context).textTheme.bodyText2.copyWith( + color: Theme.of(context) + .colorScheme + .defaultTextColor + .withOpacity(0.5), + ), + ), + ), + ); + } +} diff --git a/lib/ui/viewer/file/video_widget.dart b/lib/ui/viewer/file/video_widget.dart index 26a59353c..829429593 100644 --- a/lib/ui/viewer/file/video_widget.dart +++ b/lib/ui/viewer/file/video_widget.dart @@ -78,7 +78,9 @@ class _VideoWidgetState extends State { .getFileSize(widget.file.uploadedFileID) .then((value) { widget.file.fileSize = value; - setState(() {}); + if (mounted) { + setState(() {}); + } }); } } diff --git a/lib/ui/viewer/gallery/archive_page.dart b/lib/ui/viewer/gallery/archive_page.dart index 05b074a05..65748c354 100644 --- a/lib/ui/viewer/gallery/archive_page.dart +++ b/lib/ui/viewer/gallery/archive_page.dart @@ -66,7 +66,7 @@ class ArchivePage extends StatelessWidget { preferredSize: const Size.fromHeight(50.0), child: GalleryAppBarWidget( appBarType, - "Hidden", + "Archive", _selectedFiles, ), ), diff --git a/lib/ui/viewer/gallery/collection_page.dart b/lib/ui/viewer/gallery/collection_page.dart index dc0e5e725..7aedb8322 100644 --- a/lib/ui/viewer/gallery/collection_page.dart +++ b/lib/ui/viewer/gallery/collection_page.dart @@ -10,6 +10,7 @@ import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/gallery_type.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/ignored_files_service.dart'; +import 'package:photos/ui/viewer/gallery/empty_state.dart'; import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; import 'package:photos/ui/viewer/gallery/gallery_overlay_widget.dart'; @@ -19,16 +20,21 @@ class CollectionPage extends StatelessWidget { final String tagPrefix; final GalleryType appBarType; final _selectedFiles = SelectedFiles(); + bool hasVerifiedLock; CollectionPage( this.c, { this.tagPrefix = "collection", this.appBarType = GalleryType.ownedCollection, + this.hasVerifiedLock = false, Key key, }) : super(key: key); @override Widget build(Object context) { + if (hasVerifiedLock == false && c.collection.isHidden()) { + return const EmptyState(); + } final initialFiles = c.thumbnail != null ? [c.thumbnail] : null; final gallery = Gallery( asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async { @@ -55,6 +61,7 @@ class CollectionPage extends StatelessWidget { removalEventTypes: const { EventType.deletedFromRemote, EventType.deletedFromEverywhere, + EventType.hide, }, tagPrefix: tagPrefix, selectedFiles: _selectedFiles, diff --git a/lib/ui/viewer/gallery/device_folder_page.dart b/lib/ui/viewer/gallery/device_folder_page.dart index 1ed2ec63d..c40635ef9 100644 --- a/lib/ui/viewer/gallery/device_folder_page.dart +++ b/lib/ui/viewer/gallery/device_folder_page.dart @@ -38,6 +38,7 @@ class DeviceFolderPage extends StatelessWidget { removalEventTypes: const { EventType.deletedFromDevice, EventType.deletedFromEverywhere, + EventType.hide, }, tagPrefix: "device_folder:" + deviceCollection.name, selectedFiles: _selectedFiles, diff --git a/lib/ui/viewer/gallery/empty_hidden_widget.dart b/lib/ui/viewer/gallery/empty_hidden_widget.dart new file mode 100644 index 000000000..57c6e5a36 --- /dev/null +++ b/lib/ui/viewer/gallery/empty_hidden_widget.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/theme/text_style.dart'; + +class EmptyHiddenWidget extends StatelessWidget { + const EmptyHiddenWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final EnteTextTheme enteTextTheme = getEnteTextTheme(context); + final EnteColorScheme enteColorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.visibility_off, + color: enteColorScheme.strokeMuted, + size: 24, + ), + const SizedBox(height: 10), + Text( + "No hidden photos or videos", + textAlign: TextAlign.center, + style: enteTextTheme.body.copyWith( + color: enteColorScheme.textMuted, + ), + ), + const SizedBox(height: 36), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const EmptyHiddenTextWidget("To hide a photo or video"), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const EmptyHiddenTextWidget("• Open the item"), + const SizedBox(height: 2), + const EmptyHiddenTextWidget( + "• Click on the overflow menu", + ), + const SizedBox(height: 2), + SizedBox( + width: 120, + child: Row( + children: [ + const EmptyHiddenTextWidget("• Click "), + const SizedBox(width: 4), + Icon( + Icons.visibility_off, + color: enteColorScheme.strokeBase, + size: 16, + ), + const Padding( + padding: EdgeInsets.all(4), + ), + Text( + "Hide", + style: TextStyle( + color: enteColorScheme.textBase, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class EmptyHiddenTextWidget extends StatelessWidget { + final String text; + + const EmptyHiddenTextWidget( + this.text, { + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: getEnteColorScheme(context).textFaint, + ), + ); + } +} diff --git a/lib/ui/viewer/gallery/empty_state.dart b/lib/ui/viewer/gallery/empty_state.dart index d77025594..b2c03c9aa 100644 --- a/lib/ui/viewer/gallery/empty_state.dart +++ b/lib/ui/viewer/gallery/empty_state.dart @@ -16,6 +16,7 @@ class EmptyState extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: Text( text, + textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context) .colorScheme diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index 64409ab79..7bf2267e5 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -9,6 +9,7 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/events/event.dart'; import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/events/tab_changed_event.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/selected_files.dart'; @@ -36,8 +37,10 @@ class Gallery extends StatefulWidget { final String tagPrefix; final Widget header; final Widget footer; + final Widget emptyState; final bool smallerTodayFont; final String albumName; + final double scrollBottomSafeArea; const Gallery({ @required this.asyncLoader, @@ -49,6 +52,8 @@ class Gallery extends StatefulWidget { this.removalEventTypes = const {}, this.header, this.footer = const SizedBox(height: 120), + this.emptyState = const EmptyState(), + this.scrollBottomSafeArea = 120.0, this.smallerTodayFont = false, this.albumName = '', Key key, @@ -68,12 +73,16 @@ class _GalleryState extends State { Logger _logger; List> _collatedFiles = []; bool _hasLoadedFiles = false; + ItemScrollController _itemScroller; StreamSubscription _reloadEventSubscription; + StreamSubscription _tabDoubleTapEvent; final _forceReloadEventSubscriptions = >[]; @override void initState() { _logger = Logger("Gallery_" + widget.tagPrefix); + _itemScroller = ItemScrollController(); + _logger.info("initState"); if (widget.reloadEvent != null) { _reloadEventSubscription = widget.reloadEvent.listen((event) async { @@ -82,6 +91,17 @@ class _GalleryState extends State { _onFilesLoaded(result.files); }); } + _tabDoubleTapEvent = + Bus.instance.on().listen((event) async { + // todo: Assign ID to Gallery and fire generic event with ID & + // target index/date + if (mounted && event.selectedIndex == 0) { + _itemScroller.scrollTo( + index: 0, + duration: const Duration(milliseconds: 150), + ); + } + }); if (widget.forceReloadEvents != null) { for (final event in widget.forceReloadEvents) { _forceReloadEventSubscriptions.add( @@ -159,6 +179,7 @@ class _GalleryState extends State { @override void dispose() { _reloadEventSubscription?.cancel(); + _tabDoubleTapEvent?.cancel(); for (final subscription in _forceReloadEventSubscriptions) { subscription.cancel(); } @@ -177,10 +198,10 @@ class _GalleryState extends State { Widget _getListView() { return HugeListView>( key: _hugeListViewKey, - controller: ItemScrollController(), + controller: _itemScroller, startIndex: 0, totalCount: _collatedFiles.length, - isDraggableScrollbarEnabled: _collatedFiles.length > 30, + isDraggableScrollbarEnabled: _collatedFiles.length > 10, waitBuilder: (_) { return const EnteLoadingWidget(); }, @@ -190,8 +211,8 @@ class _GalleryState extends State { children.add(widget.header); } children.add( - const Expanded( - child: EmptyState(), + Expanded( + child: widget.emptyState, ), ); if (widget.footer != null) { @@ -239,6 +260,7 @@ class _GalleryState extends State { thumbPadding: widget.header != null ? const EdgeInsets.only(top: 60) : const EdgeInsets.all(0), + bottomSafeArea: widget.scrollBottomSafeArea, firstShown: (int firstIndex) { Bus.instance .fire(GalleryIndexUpdatedEvent(widget.tagPrefix, firstIndex)); diff --git a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index 390331e64..425daea5e 100644 --- a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -164,7 +164,7 @@ class _GalleryAppBarWidgetState extends State { Tooltip( message: "Share", child: IconButton( - icon: Icon(Icons.adaptive.share), + icon: const Icon(Icons.people_outlined), onPressed: () async { final bool showHiddenWarning = await _shouldShowHiddenFilesWarning(widget.collection); @@ -212,11 +212,11 @@ class _GalleryAppBarWidgetState extends State { value: 2, child: Row( children: [ - Icon(isArchived ? Icons.visibility : Icons.visibility_off), + Icon(isArchived ? Icons.unarchive : Icons.archive_outlined), const Padding( padding: EdgeInsets.all(8), ), - Text(isArchived ? "Unhide album" : "Hide album"), + Text(isArchived ? "Unarchive album" : "Archive album"), ], ), ), @@ -255,33 +255,34 @@ class _GalleryAppBarWidgetState extends State { ), ); } - - actions.add( - PopupMenuButton( - itemBuilder: (context) { - return items; - }, - onSelected: (value) async { - if (value == 1) { - await _renameAlbum(context); - } else if (value == 2) { - await changeCollectionVisibility( - context, - widget.collection, - widget.collection.isArchived() - ? visibilityVisible - : visibilityArchive, - ); - } else if (value == 3) { - await _trashCollection(); - } else if (value == 4) { - await _leaveAlbum(context); - } else { - showToast(context, "Something went wrong"); - } - }, - ), - ); + if (items.isNotEmpty) { + actions.add( + PopupMenuButton( + itemBuilder: (context) { + return items; + }, + onSelected: (value) async { + if (value == 1) { + await _renameAlbum(context); + } else if (value == 2) { + await changeCollectionVisibility( + context, + widget.collection, + widget.collection.isArchived() + ? visibilityVisible + : visibilityArchive, + ); + } else if (value == 3) { + await _trashCollection(); + } else if (value == 4) { + await _leaveAlbum(context); + } else { + showToast(context, "Something went wrong"); + } + }, + ), + ); + } return actions; } diff --git a/lib/ui/viewer/gallery/gallery_overlay_widget.dart b/lib/ui/viewer/gallery/gallery_overlay_widget.dart index 3bd841236..3fc3b031a 100644 --- a/lib/ui/viewer/gallery/gallery_overlay_widget.dart +++ b/lib/ui/viewer/gallery/gallery_overlay_widget.dart @@ -17,6 +17,7 @@ import 'package:photos/models/gallery_type.dart'; import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/collections_service.dart'; +import 'package:photos/services/hidden_service.dart'; import 'package:photos/ui/create_collection_page.dart'; import 'package:photos/utils/delete_file_util.dart'; import 'package:photos/utils/dialog_util.dart'; @@ -29,6 +30,7 @@ class GalleryOverlayWidget extends StatefulWidget { final SelectedFiles selectedFiles; final String path; final Collection collection; + const GalleryOverlayWidget( this.type, this.selectedFiles, { @@ -118,6 +120,7 @@ class _OverlayWidgetState extends State { StreamSubscription _userAuthEventSubscription; Function() _selectedFilesListener; final GlobalKey shareButtonKey = GlobalKey(); + @override void initState() { _selectedFilesListener = () { @@ -228,7 +231,7 @@ class _OverlayWidgetState extends State { widget.selectedFiles.clearAll(); } - Future _createAlbum() async { + Future _createCollectionAction(CollectionActionType type) async { Navigator.push( context, PageTransition( @@ -236,6 +239,7 @@ class _OverlayWidgetState extends State { child: CreateCollectionPage( widget.selectedFiles, null, + actionType: type, ), ), ); @@ -263,22 +267,39 @@ class _OverlayWidgetState extends State { } // skip add button for incoming collection till this feature is implemented if (Configuration.instance.hasConfiguredAccount() && - widget.type != GalleryType.sharedCollection) { - String msg = "Add"; + widget.type != GalleryType.sharedCollection && + widget.type != GalleryType.hidden) { + String msg = "Add to album"; IconData iconData = Platform.isAndroid ? Icons.add : CupertinoIcons.add; // show upload icon instead of add for files selected in local gallery if (widget.type == GalleryType.localFolder) { - msg = "Upload"; + msg = "Upload to album"; iconData = Icons.cloud_upload_outlined; } actions.add( Tooltip( - message: msg, + message: "add", child: IconButton( color: Theme.of(context).colorScheme.iconColor, icon: Icon(iconData), + onPressed: () async { + await onActionSelected("add"); + }, + ), + ), + ); + } + + if (Configuration.instance.hasConfiguredAccount() && + widget.type == GalleryType.hidden) { + actions.add( + Tooltip( + message: "Unhide", + child: IconButton( + color: Theme.of(context).colorScheme.iconColor, + icon: const Icon(Icons.visibility), onPressed: () { - _createAlbum(); + _createCollectionAction(CollectionActionType.unHide); }, ), ), @@ -298,7 +319,7 @@ class _OverlayWidgetState extends State { : CupertinoIcons.arrow_right, ), onPressed: () { - _moveFiles(); + onActionSelected('move'); }, ), ), @@ -319,6 +340,7 @@ class _OverlayWidgetState extends State { ); if (widget.type == GalleryType.homepage || widget.type == GalleryType.archive || + widget.type == GalleryType.hidden || widget.type == GalleryType.localFolder || widget.type == GalleryType.searchResults) { actions.add( @@ -371,27 +393,66 @@ class _OverlayWidgetState extends State { if (widget.type == GalleryType.homepage || widget.type == GalleryType.archive) { final bool showArchive = widget.type == GalleryType.homepage; - actions.add( - Tooltip( - message: showArchive ? "Hide" : "Unhide", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: Icon( - showArchive ? Icons.visibility_off : Icons.visibility, + if (showArchive) { + actions.add( + Tooltip( + message: 'Archive', + child: IconButton( + color: Theme.of(context).colorScheme.iconColor, + icon: const Icon( + Icons.archive_outlined, + ), + onPressed: () { + onActionSelected('archive'); + }, ), - onPressed: () { - _handleVisibilityChangeRequest( - context, - showArchive ? visibilityArchive : visibilityVisible, - ); - }, ), - ), - ); + ); + } else { + actions.insert( + 0, + Tooltip( + message: 'Unarchive', + child: IconButton( + color: Theme.of(context).colorScheme.iconColor, + icon: const Icon( + Icons.unarchive, + ), + onPressed: () { + onActionSelected('unarchive'); + }, + ), + ), + ); + } } + return actions; } + Future onActionSelected(String value) async { + debugPrint("Action Selected $value"); + switch (value.toLowerCase()) { + case 'hide': + await _handleHideRequest(context); + break; + case 'add': + await _createCollectionAction(CollectionActionType.addFiles); + break; + case 'move': + await _moveFiles(); + break; + case 'archive': + await _handleVisibilityChangeRequest(context, visibilityArchive); + break; + case 'unarchive': + await _handleVisibilityChangeRequest(context, visibilityVisible); + break; + default: + break; + } + } + void _addTrashAction(List actions) { actions.add( Tooltip( @@ -456,6 +517,21 @@ class _OverlayWidgetState extends State { } } + // note: Keeping this method here so that it can be used whenever we move to + // to bottom UI + Future _handleHideRequest(BuildContext context) async { + try { + final hideResult = await CollectionsService.instance + .hideFiles(context, widget.selectedFiles.files.toList()); + if (hideResult) { + _clearSelectedFiles(); + } + } catch (e, s) { + _logger.severe("failed to update file visibility", e, s); + await showGenericErrorDialog(context); + } + } + void _shareSelected(BuildContext context) { share( context, diff --git a/lib/ui/viewer/gallery/hidden_page.dart b/lib/ui/viewer/gallery/hidden_page.dart new file mode 100644 index 000000000..f92853a4c --- /dev/null +++ b/lib/ui/viewer/gallery/hidden_page.dart @@ -0,0 +1,87 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/db/files_db.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/models/gallery_type.dart'; +import 'package:photos/models/selected_files.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/ui/viewer/gallery/empty_hidden_widget.dart'; +import 'package:photos/ui/viewer/gallery/gallery.dart'; +import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; +import 'package:photos/ui/viewer/gallery/gallery_overlay_widget.dart'; + +class HiddenPage extends StatelessWidget { + final String tagPrefix; + final GalleryType appBarType; + final GalleryType overlayType; + final _selectedFiles = SelectedFiles(); + + HiddenPage({ + this.tagPrefix = "hidden_page", + this.appBarType = GalleryType.hidden, + this.overlayType = GalleryType.hidden, + Key key, + }) : super(key: key); + + @override + Widget build(Object context) { + final gallery = Gallery( + asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) { + return FilesDB.instance.getFilesInCollections( + CollectionsService.instance.getHiddenCollections().toList(), + creationStartTime, + creationEndTime, + Configuration.instance.getUserID(), + limit: limit, + asc: asc, + ); + }, + reloadEvent: Bus.instance.on().where( + (event) => + event.updatedFiles.firstWhere( + (element) => element.uploadedFileID != null, + orElse: () => null, + ) != + null, + ), + removalEventTypes: const {EventType.unhide}, + forceReloadEvents: [ + Bus.instance.on().where( + (event) => + event.updatedFiles.firstWhere( + (element) => element.uploadedFileID != null, + orElse: () => null, + ) != + null, + ), + ], + tagPrefix: tagPrefix, + selectedFiles: _selectedFiles, + initialFiles: null, + emptyState: const EmptyHiddenWidget(), + ); + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(50.0), + child: GalleryAppBarWidget( + appBarType, + "Hidden", + _selectedFiles, + ), + ), + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + gallery, + GalleryOverlayWidget( + overlayType, + _selectedFiles, + ) + ], + ), + ); + } +} diff --git a/lib/ui/viewer/search/result/no_result_widget.dart b/lib/ui/viewer/search/result/no_result_widget.dart index 25a0a379d..ecb6ee2b0 100644 --- a/lib/ui/viewer/search/result/no_result_widget.dart +++ b/lib/ui/viewer/search/result/no_result_widget.dart @@ -29,14 +29,13 @@ class NoResultWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - margin: const EdgeInsets.all(8), - child: const Text( - "No results found", - style: TextStyle( - fontSize: 16, - ), + Container( + margin: const EdgeInsets.only(top: 8), + child: const Text( + "No results found", + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 16, ), ), ), @@ -61,6 +60,7 @@ class NoResultWidget extends StatelessWidget { \u2022 Types of files (e.g. "Videos", ".gif") \u2022 Years and months (e.g. "2022", "January") \u2022 Holidays (e.g. "Christmas") +\u2022 Photo descriptions (e.g. “#fun”) ''', style: TextStyle( fontSize: 14, diff --git a/lib/ui/viewer/search/result/search_result_widget.dart b/lib/ui/viewer/search/result/search_result_widget.dart index 7588975f0..5825094d2 100644 --- a/lib/ui/viewer/search/result/search_result_widget.dart +++ b/lib/ui/viewer/search/result/search_result_widget.dart @@ -125,6 +125,8 @@ class SearchResultWidget extends StatelessWidget { return "Type"; case ResultType.fileExtension: return "File Extension"; + case ResultType.fileCaption: + return "Description"; default: return type.name.toUpperCase(); } diff --git a/lib/ui/viewer/search/search_widget.dart b/lib/ui/viewer/search/search_widget.dart index 947f14619..dbe1376ec 100644 --- a/lib/ui/viewer/search/search_widget.dart +++ b/lib/ui/viewer/search/search_widget.dart @@ -8,6 +8,7 @@ import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/search/search_result.dart'; import 'package:photos/services/feature_flag_service.dart'; import 'package:photos/services/search_service.dart'; +import 'package:photos/ui/components/icon_button_widget.dart'; import 'package:photos/ui/viewer/search/result/no_result_widget.dart'; import 'package:photos/ui/viewer/search/search_suffix_icon_widget.dart'; import 'package:photos/ui/viewer/search/search_suggestions.dart'; @@ -32,9 +33,10 @@ class _SearchIconWidgetState extends State { Widget build(BuildContext context) { return Hero( tag: "search_icon", - child: IconButton( - visualDensity: const VisualDensity(horizontal: -2, vertical: -2), - onPressed: () { + child: IconButtonWidget( + iconButtonType: IconButtonType.primary, + icon: Icons.search, + onTap: () { Navigator.push( context, TransparentRoute( @@ -42,7 +44,6 @@ class _SearchIconWidgetState extends State { ), ); }, - icon: const Icon(Icons.search), ), ); } @@ -75,7 +76,6 @@ class _SearchWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( children: [ - const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(8), child: Container( @@ -196,6 +196,9 @@ class _SearchWidgetState extends State { await _searchService.getFileTypeResults(query); allResults.addAll(fileTypeSearchResults); + final fileCaptionResults = await _searchService.getCaptionResults(query); + allResults.addAll(fileCaptionResults); + final fileExtnResult = await _searchService.getFileExtensionResults(query); allResults.addAll(fileExtnResult); diff --git a/lib/utils/data_util.dart b/lib/utils/data_util.dart index 3dcae58fa..a21bc4557 100644 --- a/lib/utils/data_util.dart +++ b/lib/utils/data_util.dart @@ -1,11 +1,5 @@ import 'dart:math'; -double convertBytesToGBs(final int bytes, {int precision = 2}) { - return double.parse( - (bytes / (1024 * 1024 * 1024)).toStringAsFixed(precision), - ); -} - final storageUnits = ["bytes", "KB", "MB", "GB"]; String convertBytesToReadableFormat(int bytes) { @@ -24,3 +18,23 @@ String formatBytes(int bytes, [int decimals = 2]) { final int i = (log(bytes) / log(k)).floor(); return ((bytes / pow(k, i)).toStringAsFixed(dm)) + ' ' + storageUnits[i]; } + +//shows 1st decimal only if less than 10GB & omits decimal if decimal is 0 +num roundBytesUsedToGBs(int usedBytes, int freeSpace) { + const tenGBinBytes = 10737418240; + num bytesInGB = convertBytesToGBs(usedBytes); + if ((usedBytes >= tenGBinBytes && freeSpace >= tenGBinBytes) || + bytesInGB % 1 == 0) { + bytesInGB = bytesInGB.truncate(); + } + return bytesInGB; +} + +//Eg: 0.3 GB, 11.0 GB, 532.3 GB +num convertBytesToGBs(int bytes) { + return num.parse((bytes / (pow(1024, 3))).toStringAsFixed(1)); +} + +int convertBytesToMBs(int bytes) { + return (bytes / pow(1024, 2)).round(); +} diff --git a/lib/utils/date_time_util.dart b/lib/utils/date_time_util.dart index 13688ebd9..61c9ec74d 100644 --- a/lib/utils/date_time_util.dart +++ b/lib/utils/date_time_util.dart @@ -268,3 +268,23 @@ bool isValidDate({ } return true; } + +DateTime? parseDateFromFileName(String fileName) { + if (fileName.startsWith('IMG-') || fileName.startsWith('VID-')) { +// Whatsapp media files + return DateTime.tryParse(fileName.split('-')[1]); + } else if (fileName.startsWith("Screenshot_")) { +// Screenshots on droid + return DateTime.tryParse( + (fileName).replaceAll('Screenshot_', '').replaceAll('-', 'T'), + ); + } else { + return DateTime.tryParse( + (fileName) + .replaceAll("IMG_", "") + .replaceAll("VID_", "") + .replaceAll("DCIM_", "") + .replaceAll("_", " "), + ); + } +} diff --git a/lib/utils/exif_util.dart b/lib/utils/exif_util.dart index dd1eff63e..3a284f067 100644 --- a/lib/utils/exif_util.dart +++ b/lib/utils/exif_util.dart @@ -1,5 +1,3 @@ -// @dart=2.9 - import 'dart:io' as io; import 'package:exif/exif.dart'; @@ -29,18 +27,16 @@ Future> getExif(File file) async { } } -Future getCreationTimeFromEXIF(io.File file) async { +Future getCreationTimeFromEXIF(io.File file) async { try { final exif = await readExifFromFile(file); - if (exif != null) { - final exifTime = exif.containsKey(kDateTimeOriginal) - ? exif[kDateTimeOriginal].printable - : exif.containsKey(kImageDateTime) - ? exif[kImageDateTime].printable - : null; - if (exifTime != null && exifTime != kEmptyExifDateTime) { - return DateFormat(kExifDateTimePattern).parse(exifTime); - } + final exifTime = exif.containsKey(kDateTimeOriginal) + ? exif[kDateTimeOriginal]!.printable + : exif.containsKey(kImageDateTime) + ? exif[kImageDateTime]!.printable + : null; + if (exifTime != null && exifTime != kEmptyExifDateTime) { + return DateFormat(kExifDateTimePattern).parse(exifTime); } } catch (e) { _logger.severe("failed to getCreationTimeFromEXIF", e); diff --git a/lib/utils/file_uploader.dart b/lib/utils/file_uploader.dart index 9869f99f3..d71f17dfc 100644 --- a/lib/utils/file_uploader.dart +++ b/lib/utils/file_uploader.dart @@ -188,7 +188,8 @@ class FileUploader { _queue.remove(id).completer.completeError(reason); } _logger.info( - 'number of enteries removed from queue ${uploadsToBeRemoved.length}'); + 'number of enteries removed from queue ${uploadsToBeRemoved.length}', + ); _totalCountInUploadSession -= uploadsToBeRemoved.length; } diff --git a/lib/utils/magic_util.dart b/lib/utils/magic_util.dart index e0f480980..d41b26ce4 100644 --- a/lib/utils/magic_util.dart +++ b/lib/utils/magic_util.dart @@ -10,6 +10,7 @@ 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/common/progress_dialog.dart'; import 'package:photos/ui/common/rename_dialog.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/toast_util.dart'; @@ -23,7 +24,10 @@ Future changeVisibility( ) async { final dialog = createProgressDialog( context, - newVisibility == visibilityArchive ? "Hiding..." : "Unhiding...", + newVisibility == visibilityArchive + ? "Archiving..." + : "Unarchiving..." + "...", ); await dialog.show(); try { @@ -31,8 +35,8 @@ Future changeVisibility( showShortToast( context, newVisibility == visibilityArchive - ? "Successfully hidden" - : "Successfully unhidden", + ? "Successfully archived" + : "Successfully unarchived", ); await dialog.hide(); @@ -50,7 +54,7 @@ Future changeCollectionVisibility( ) async { final dialog = createProgressDialog( context, - newVisibility == visibilityArchive ? "Hiding..." : "Unhiding...", + newVisibility == visibilityArchive ? "Archiving..." : "Unarchiving...", ); await dialog.show(); try { @@ -61,8 +65,8 @@ Future changeCollectionVisibility( showShortToast( context, newVisibility == visibilityArchive - ? "Successfully hidden" - : "Successfully unhidden", + ? "Successfully archived" + : "Successfully unarchived", ); await dialog.hide(); @@ -120,7 +124,23 @@ Future editFilename( ); return true; } catch (e) { - showToast(context, 'something went wrong'); + showToast(context, 'Something went wrong'); + return false; + } +} + +Future editFileCaption( + BuildContext context, + File file, + String caption, +) async { + try { + await _updatePublicMetadata(context, [file], pubMagicKeyCaption, caption); + return true; + } catch (e) { + if (context != null) { + showToast(context, "Something went wrong"); + } return false; } } @@ -134,19 +154,27 @@ Future _updatePublicMetadata( if (files.isEmpty) { return; } - final dialog = createProgressDialog(context, 'please wait...'); - await dialog.show(); + ProgressDialog dialog; + if (context != null) { + dialog = createProgressDialog(context, 'Please wait...'); + await dialog.show(); + } try { final Map update = {key: value}; await FileMagicService.instance.updatePublicMagicMetadata(files, update); - showShortToast(context, 'done'); - await dialog.hide(); + if (context != null) { + showShortToast(context, 'Done'); + await dialog.hide(); + } + if (_shouldReloadGallery(key)) { Bus.instance.fire(ForceReloadHomeGalleryEvent()); } } catch (e, s) { _logger.severe("failed to update $key = $value", e, s); - await dialog.hide(); + if (context != null) { + await dialog.hide(); + } rethrow; } } diff --git a/lib/utils/share_util.dart b/lib/utils/share_util.dart index 2a3562081..476f1825b 100644 --- a/lib/utils/share_util.dart +++ b/lib/utils/share_util.dart @@ -10,6 +10,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_type.dart'; +import 'package:photos/utils/date_time_util.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/exif_util.dart'; import 'package:photos/utils/file_util.dart'; @@ -111,7 +112,7 @@ Future> convertIncomingSharedMediaToFile( return localFiles; } -DateTime parseDateFromFileName(String fileName) { +DateTime parseDateFromFileNam1e(String fileName) { if (fileName.startsWith('IMG-') || fileName.startsWith('VID-')) { // Whatsapp media files return DateTime.tryParse(fileName.split('-')[1]); diff --git a/pubspec.lock b/pubspec.lock index 86790471e..7b1524c65 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -411,7 +411,7 @@ packages: name: flutter_inappwebview url: "https://pub.dartlang.org" source: hosted - version: "5.5.0+2" + version: "5.7.1" flutter_keyboard_visibility: dependency: transitive description: @@ -744,6 +744,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + modal_bottom_sheet: + dependency: "direct main" + description: + name: modal_bottom_sheet + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" motionphoto: dependency: "direct main" description: @@ -927,7 +934,7 @@ packages: name: photo_manager url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.5.0" photo_view: dependency: "direct main" description: @@ -1004,7 +1011,7 @@ packages: name: scrollable_positioned_list url: "https://pub.dartlang.org" source: hosted - version: "0.2.3" + version: "0.3.5" sentry: dependency: "direct main" description: @@ -1373,7 +1380,7 @@ packages: name: visibility_detector url: "https://pub.dartlang.org" source: hosted - version: "0.2.2" + version: "0.3.3" wakelock: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index db3a1be67..b4ee205fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.47+377 +version: 0.6.52+382 environment: sdk: '>=2.17.0 <3.0.0' @@ -79,6 +79,7 @@ dependencies: lottie: ^1.2.2 media_extension: git: "https://github.com/ente-io/media_extension.git" + modal_bottom_sheet: ^2.1.2 motionphoto: git: "https://github.com/ente-io/motionphoto.git" move_to_background: ^1.0.2 @@ -91,13 +92,13 @@ dependencies: path: #dart path_provider: ^2.0.1 pedantic: ^1.9.2 - photo_manager: 2.1.4 + photo_manager: ^2.5.0 photo_view: ^0.14.0 pinput: ^1.2.2 provider: ^6.0.0 quiver: ^3.0.1 receive_sharing_intent: ^1.4.5 - scrollable_positioned_list: ^0.2.2 + scrollable_positioned_list: ^0.3.5 sentry: ^6.12.1 sentry_flutter: ^6.12.1 share_plus: ^4.0.10 @@ -114,7 +115,7 @@ dependencies: video_player: path: thirdparty/plugins/packages/video_player/video_player video_thumbnail: ^0.4.3 - visibility_detector: ^0.2.2 + visibility_detector: ^0.3.3 wakelock: ^0.6.1+2 wallpaper_manager_flutter: ^0.0.2