diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c51c196f..bdc15ca9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.6.3] - 2022-03-28 + +### Added + +- Theme: light/dark/black and color highlights settings +- Collection: bulk renaming +- Video: speed and muted state indicators +- Info: option to set date from other item +- Info: improved DNG tags display +- warn and optionally set metadata date before moving undated items +- Settings: display refresh rate hint + +### Changed + +- Viewer: quick action defaults +- cataloguing includes date sub-second data if present (requires rescan) + +### Removed + +- metadata editing support for DNG + +### Fixed + +- app launch despite faulty storage volumes on Android 11+ + ## [v1.6.2] - 2022-03-07 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index ccdb492b5..7b9253ca2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -147,7 +147,7 @@ dependencies { implementation 'androidx.multidex:multidex:2.0.1' implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.commonsware.cwac:document:0.4.1' - implementation 'com.drewnoakes:metadata-extractor:2.16.0' + implementation 'com.drewnoakes:metadata-extractor:2.17.0' // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index f90d01668..13f1d7800 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -164,11 +164,18 @@ class MainActivity : FlutterActivity() { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) + val canPersist = (data.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0 + if (canPersist) { + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + try { + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + } catch (e: SecurityException) { + Log.w(LOG_TAG, "failed to take persistable URI permission for uri=$treeUri", e) + } + } } // resume pending action @@ -201,9 +208,11 @@ class MainActivity : FlutterActivity() { } Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> { (intent.data ?: (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri))?.let { uri -> + // MIME type is optional + val type = intent.type ?: intent.resolveType(context) return hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW, - INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional + INTENT_DATA_KEY_MIME_TYPE to type, INTENT_DATA_KEY_URI to uri.toString(), ) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 82c84520e..6f555b18e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -33,7 +33,10 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.util.PathUtils -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.IOException @@ -84,7 +87,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } }.mapValues { it.value?.path }.toMutableMap() dirs["externalCacheDirs"] = context.externalCacheDirs.joinToString { it.path } - dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it.path } + dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it?.path ?: "null" } // used by flutter plugin `path_provider` dirs.putAll( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index e70895276..2d4b722c2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -47,6 +47,9 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateDigitizedMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateModifiedMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateOriginalMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt @@ -74,7 +77,10 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.text.ParseException @@ -163,15 +169,24 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // tags val tags = dir.tags if (dir is ExifDirectoryBase) { - if (dir.isGeoTiff()) { - // split GeoTIFF tags in their own directory - val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } - metadataMap["GeoTIFF"] = HashMap().apply { - byGeoTiff[true]?.map { exifTagMapper(it) }?.let { putAll(it) } + when { + dir.isGeoTiff() -> { + // split GeoTIFF tags in their own directory + val geoTiffDirMap = metadataMap["GeoTIFF"] ?: HashMap() + metadataMap["GeoTIFF"] = geoTiffDirMap + val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } + byGeoTiff[true]?.map { exifTagMapper(it) }?.let { geoTiffDirMap.putAll(it) } + byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } } - byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } - } else { - dirMap.putAll(tags.map { exifTagMapper(it) }) + mimeType == MimeTypes.DNG -> { + // split DNG tags in their own directory + val dngDirMap = metadataMap["DNG"] ?: HashMap() + metadataMap["DNG"] = dngDirMap + val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) } + byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) } + byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } + } + else -> dirMap.putAll(tags.map { exifTagMapper(it) }) } } else if (dir.isPngTextDir()) { metadataMap.remove(thisDirName) @@ -432,11 +447,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // EXIF for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getDateOriginalMillis { metadataMap[KEY_DATE_MILLIS] = it } + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + // fetch date modified from SubIFD directory first, as the sub-second tag is here + dir.getDateModifiedMillis { metadataMap[KEY_DATE_MILLIS] = it } + } } for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + // fallback to fetch date modified from IFD0 directory, without the sub-second tag + // in case there was no SubIFD directory + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { metadataMap[KEY_DATE_MILLIS] = it } } dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) { val orientation = it @@ -560,9 +581,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL, ExifInterface.TAG_SUBSEC_TIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME, ExifInterface.TAG_SUBSEC_TIME) { metadataMap[KEY_DATE_MILLIS] = it } } exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { if (exif.isFlipped) flags = flags or MASK_IS_FLIPPED @@ -901,9 +922,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) val tag = when (field) { - ExifInterface.TAG_DATETIME -> ExifDirectoryBase.TAG_DATETIME - ExifInterface.TAG_DATETIME_DIGITIZED -> ExifDirectoryBase.TAG_DATETIME_DIGITIZED - ExifInterface.TAG_DATETIME_ORIGINAL -> ExifDirectoryBase.TAG_DATETIME_ORIGINAL + ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL ExifInterface.TAG_GPS_DATESTAMP -> GpsDirectory.TAG_DATE_STAMP else -> { result.error("getDate-field", "unsupported ExifInterface field=$field", null) @@ -912,11 +933,24 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } when (tag) { - ExifDirectoryBase.TAG_DATETIME, - ExifDirectoryBase.TAG_DATETIME_DIGITIZED, - ExifDirectoryBase.TAG_DATETIME_ORIGINAL -> { - for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) { - dir.getSafeDateMillis(tag) { dateMillis = it } + ExifIFD0Directory.TAG_DATETIME -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateModifiedMillis { dateMillis = it } + } + if (dateMillis == null) { + for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { dateMillis = it } + } + } + } + ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateDigitizedMillis { dateMillis = it } + } + } + ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateOriginalMillis { dateMillis = it } } } GpsDirectory.TAG_DATE_STAMP -> { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 7a8f02ae1..568b0be85 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -199,27 +199,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments } private suspend fun rename() { - if (arguments !is Map<*, *> || entryMapList.isEmpty()) { + if (arguments !is Map<*, *>) { endOfStream() return } - val newName = arguments["newName"] as String? - if (newName == null) { + val rawEntryMap = arguments["entriesToNewName"] as Map<*, *>? + if (rawEntryMap == null || rawEntryMap.isEmpty()) { error("rename-args", "failed because of missing arguments", null) return } + val entriesToNewName = HashMap() + rawEntryMap.forEach { + @Suppress("unchecked_cast") + val rawEntry = it.key as FieldMap + val newName = it.value as String + entriesToNewName[AvesEntry(rawEntry)] = newName + } + // assume same provider for all entries - val firstEntry = entryMapList.first() - val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } + val firstEntry = entriesToNewName.keys.first() + val provider = getProvider(firstEntry.uri) if (provider == null) { error("rename-provider", "failed to find provider for entry=$firstEntry", null) return } - val entries = entryMapList.map(::AvesEntry) - provider.renameMultiple(activity, newName, entries, ::isCancelledOp, object : ImageOpCallback { + provider.renameMultiple(activity, entriesToNewName, ::isCancelledOp, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 0bc307f92..1e742403c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -13,6 +13,7 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.PermissionManager +import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.CoroutineScope @@ -80,6 +81,11 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? return } + if (uris.any { !StorageUtils.isMediaStoreContentUri(it) }) { + error("requestMediaFileAccess-nonmediastore", "request is only valid for Media Store content URIs, uris=$uris", null) + return + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null) return @@ -148,12 +154,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? fun onGranted(uri: Uri) { ioScope.launch { - activity.contentResolver.openInputStream(uri)?.use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var len: Int - while (input.read(buffer).also { len = it } != -1) { - success(buffer.copyOf(len)) + try { + activity.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + success(buffer.copyOf(len)) + } } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to open input stream for uri=$uri", e) + } finally { endOfStream() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/DngTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/DngTags.kt new file mode 100644 index 000000000..313791999 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/DngTags.kt @@ -0,0 +1,229 @@ +package deckers.thibault.aves.metadata + +// DNG v1.6.0.0 +// cf https://helpx.adobe.com/content/dam/help/en/photoshop/pdf/dng_spec_1_6_0_0.pdf +object DngTags { + private const val DNG_VERSION = 0xC612 + private const val DNG_BACKWARD_VERSION = 0xC613 + private const val UNIQUE_CAMERA_MODEL = 0xC614 + private const val LOCALIZED_CAMERA_MODEL = 0xC615 + private const val CFA_PLANE_COLOR = 0xC616 + private const val CFA_LAYOUT = 0xC617 + private const val LINEARIZATION_TABLE = 0xC618 + private const val BLACK_LEVEL_REPEAT_DIM = 0xC619 + private const val BLACK_LEVEL = 0xC61A + private const val BLACK_LEVEL_DELTA_H = 0xC61B + private const val BLACK_LEVEL_DELTA_V = 0xC61C + private const val WHITE_LEVEL = 0xC61D + private const val DEFAULT_SCALE = 0xC61E + private const val DEFAULT_CROP_ORIGIN = 0xC61F + private const val DEFAULT_CROP_SIZE = 0xC620 + private const val COLOR_MATRIX_1 = 0xC621 + private const val COLOR_MATRIX_2 = 0xC622 + private const val CAMERA_CALIBRATION_1 = 0xC623 + private const val CAMERA_CALIBRATION_2 = 0xC624 + private const val REDUCTION_MATRIX_1 = 0xC625 + private const val REDUCTION_MATRIX_2 = 0xC626 + private const val ANALOG_BALANCE = 0xC627 + private const val AS_SHOT_NEUTRAL = 0xC628 + private const val AS_SHOT_WHITE_XY = 0xC629 + private const val BASELINE_EXPOSURE = 0xC62A + private const val BASELINE_NOISE = 0xC62B + private const val BASELINE_SHARPNESS = 0xC62C + private const val BAYER_GREEN_SPLIT = 0xC62D + private const val LINEAR_RESPONSE_LIMIT = 0xC62E + private const val CAMERA_SERIAL_NUMBER = 0xC62F + private const val LENS_INFO = 0xC630 + private const val CHROMA_BLUR_RADIUS = 0xC631 + private const val ANTI_ALIAS_STRENGTH = 0xC632 + private const val SHADOW_SCALE = 0xC633 + private const val DNG_PRIVATE_DATA = 0xC634 + private const val MAKER_NOTE_SAFETY = 0xC635 + private const val CALIBRATION_ILLUMINANT_1 = 0xC65A + private const val CALIBRATION_ILLUMINANT_2 = 0xC65B + private const val BEST_QUALITY_SCALE = 0xC65C + private const val RAW_DATA_UNIQUE_ID = 0xC65D + private const val ORIGINAL_RAW_FILE_NAME = 0xC68B + private const val ORIGINAL_RAW_FILE_DATA = 0xC68C + private const val ACTIVE_AREA = 0xC68D + private const val MASKED_AREAS = 0xC68E + private const val AS_SHOT_ICC_PROFILE = 0xC68F + private const val AS_SHOT_PRE_PROFILE_MATRIX = 0xC690 + private const val CURRENT_ICC_PROFILE = 0xC691 + private const val CURRENT_PRE_PROFILE_MATRIX = 0xC692 + private const val COLORIMETRIC_REFERENCE = 0xC6BF + private const val CAMERA_CALIBRATION_SIGNATURE = 0xC6F3 + private const val PROFILE_CALIBRATION_SIGNATURE = 0xC6F4 + private const val EXTRA_CAMERA_PROFILES = 0xC6F5 + private const val AS_SHOT_PROFILE_NAME = 0xC6F6 + private const val NOISE_REDUCTION_APPLIED = 0xC6F7 + private const val PROFILE_NAME = 0xC6F8 + private const val PROFILE_HUE_SAT_MAP_DIMS = 0xC6F9 + private const val PROFILE_HUE_SAT_MAP_DATA_1 = 0xC6FA + private const val PROFILE_HUE_SAT_MAP_DATA_2 = 0xC6FB + private const val PROFILE_TONE_CURVE = 0xC6FC + private const val PROFILE_EMBED_POLICY = 0xC6FD + private const val PROFILE_COPYRIGHT = 0xC6FE + private const val FORWARD_MATRIX_1 = 0xC714 + private const val FORWARD_MATRIX_2 = 0xC715 + private const val PREVIEW_APPLICATION_NAME = 0xC716 + private const val PREVIEW_APPLICATION_VERSION = 0xC717 + private const val PREVIEW_SETTINGS_NAME = 0xC718 + private const val PREVIEW_SETTINGS_DIGEST = 0xC719 + private const val PREVIEW_COLOR_SPACE = 0xC71A + private const val PREVIEW_DATE_TIME = 0xC71B + private const val RAW_IMAGE_DIGEST = 0xC71C + private const val ORIGINAL_RAW_FILE_DIGEST = 0xC71D + private const val SUB_TILE_BLOCK_SIZE = 0xC71E + private const val ROW_INTERLEAVE_FACTOR = 0xC71F + private const val PROFILE_LOOK_TABLE_DIMS = 0xC725 + private const val PROFILE_LOOK_TABLE_DATA = 0xC726 + private const val OPCODE_LIST_1 = 0xC740 + private const val OPCODE_LIST_2 = 0xC741 + private const val OPCODE_LIST_3 = 0xC74E + private const val NOISE_PROFILE = 0xC761 + private const val ORIGINAL_DEFAULT_FINAL_SIZE = 0xC791 + private const val ORIGINAL_BEST_QUALITY_FINAL_SIZE = 0xC792 + private const val ORIGINAL_DEFAULT_CROP_SIZE = 0xC793 + private const val PROFILE_HUE_SAT_MAP_ENCODING = 0xC7A3 + private const val PROFILE_LOOK_TABLE_ENCODING = 0xC7A4 + private const val BASELINE_EXPOSURE_OFFSET = 0xC7A5 + private const val DEFAULT_BLACK_RENDER = 0xC7A6 + private const val NEW_RAW_IMAGE_DIGEST = 0xC7A7 + private const val RAW_TO_PREVIEW_GAIN = 0xC7A8 + private const val DEFAULT_USER_CROP = 0xC7B5 + private const val DEPTH_FORMAT = 0xC7E9 + private const val DEPTH_NEAR = 0xC7EA + private const val DEPTH_FAR = 0xC7EB + private const val DEPTH_UNITS = 0xC7EC + private const val DEPTH_MEASURE_TYPE = 0xC7ED + private const val ENHANCE_PARAMS = 0xC7EE + private const val PROFILE_GAIN_TABLE_MAP = 0xCD2D + private const val SEMANTIC_NAME = 0xCD2E + private const val SEMANTIC_INSTANCE_ID = 0xCD30 + private const val CALIBRATION_ILLUMINANT_3 = 0xCD31 + private const val CAMERA_CALIBRATION_3 = 0xCD32 + private const val COLOR_MATRIX_3 = 0xCD33 + private const val FORWARD_MATRIX_3 = 0xCD34 + private const val ILLUMINANT_DATA_1 = 0xCD35 + private const val ILLUMINANT_DATA_2 = 0xCD36 + private const val ILLUMINANT_DATA_3 = 0xCD37 + private const val MASK_SUB_AREA = 0xCD38 + private const val PROFILE_HUE_SAT_MAP_DATA_3 = 0xCD39 + private const val REDUCTION_MATRIX_3 = 0xCD3A + private const val RGB_TABLES = 0xCD3F + + val tagNameMap = hashMapOf( + DNG_VERSION to "DNG Version", + DNG_BACKWARD_VERSION to "DNG Backward Version", + UNIQUE_CAMERA_MODEL to "Unique Camera Model", + LOCALIZED_CAMERA_MODEL to "Localized Camera Model", + CFA_PLANE_COLOR to "CFA Plane Color", + CFA_LAYOUT to "CFA Layout", + LINEARIZATION_TABLE to "Linearization Table", + BLACK_LEVEL_REPEAT_DIM to "Black Level Repeat Dim", + BLACK_LEVEL to "Black Level", + BLACK_LEVEL_DELTA_H to "Black Level Delta H", + BLACK_LEVEL_DELTA_V to "Black Level Delta V", + WHITE_LEVEL to "White Level", + DEFAULT_SCALE to "Default Scale", + DEFAULT_CROP_ORIGIN to "Default Crop Origin", + DEFAULT_CROP_SIZE to "Default Crop Size", + COLOR_MATRIX_1 to "Color Matrix 1", + COLOR_MATRIX_2 to "Color Matrix 2", + CAMERA_CALIBRATION_1 to "Camera Calibration 1", + CAMERA_CALIBRATION_2 to "Camera Calibration 2", + REDUCTION_MATRIX_1 to "Reduction Matrix 1", + REDUCTION_MATRIX_2 to "Reduction Matrix 2", + ANALOG_BALANCE to "Analog Balance", + AS_SHOT_NEUTRAL to "As Shot Neutral", + AS_SHOT_WHITE_XY to "As Shot White XY", + BASELINE_EXPOSURE to "Baseline Exposure", + BASELINE_NOISE to "Baseline Noise", + BASELINE_SHARPNESS to "Baseline Sharpness", + BAYER_GREEN_SPLIT to "Bayer Green Split", + LINEAR_RESPONSE_LIMIT to "Linear Response Limit", + CAMERA_SERIAL_NUMBER to "Camera Serial Number", + LENS_INFO to "Lens Info", + CHROMA_BLUR_RADIUS to "Chroma Blur Radius", + ANTI_ALIAS_STRENGTH to "Anti Alias Strength", + SHADOW_SCALE to "Shadow Scale", + DNG_PRIVATE_DATA to "DNG Private Data", + MAKER_NOTE_SAFETY to "Maker Note Safety", + CALIBRATION_ILLUMINANT_1 to "Calibration Illuminant 1", + CALIBRATION_ILLUMINANT_2 to "Calibration Illuminant 2", + BEST_QUALITY_SCALE to "Best Quality Scale", + RAW_DATA_UNIQUE_ID to "Raw Data Unique ID", + ORIGINAL_RAW_FILE_NAME to "Original Raw File Name", + ORIGINAL_RAW_FILE_DATA to "Original Raw File Data", + ACTIVE_AREA to "Active Area", + MASKED_AREAS to "Masked Areas", + AS_SHOT_ICC_PROFILE to "As Shot ICC Profile", + AS_SHOT_PRE_PROFILE_MATRIX to "As Shot Pre Profile Matrix", + CURRENT_ICC_PROFILE to "Current ICC Profile", + CURRENT_PRE_PROFILE_MATRIX to "Current Pre Profile Matrix", + COLORIMETRIC_REFERENCE to "Colorimetric Reference", + CAMERA_CALIBRATION_SIGNATURE to "Camera Calibration Signature", + PROFILE_CALIBRATION_SIGNATURE to "Profile Calibration Signature", + EXTRA_CAMERA_PROFILES to "Extra Camera Profiles", + AS_SHOT_PROFILE_NAME to "As Shot Profile Name", + NOISE_REDUCTION_APPLIED to "Noise Reduction Applied", + PROFILE_NAME to "Profile Name", + PROFILE_HUE_SAT_MAP_DIMS to "Profile Hue Sat Map Dims", + PROFILE_HUE_SAT_MAP_DATA_1 to "Profile Hue Sat Map Data 1", + PROFILE_HUE_SAT_MAP_DATA_2 to "Profile Hue Sat Map Data 2", + PROFILE_TONE_CURVE to "Profile Tone Curve", + PROFILE_EMBED_POLICY to "Profile Embed Policy", + PROFILE_COPYRIGHT to "Profile Copyright", + FORWARD_MATRIX_1 to "Forward Matrix 1", + FORWARD_MATRIX_2 to "Forward Matrix 2", + PREVIEW_APPLICATION_NAME to "Preview Application Name", + PREVIEW_APPLICATION_VERSION to "Preview Application Version", + PREVIEW_SETTINGS_NAME to "Preview Settings Name", + PREVIEW_SETTINGS_DIGEST to "Preview Settings Digest", + PREVIEW_COLOR_SPACE to "Preview Color Space", + PREVIEW_DATE_TIME to "Preview Date Time", + RAW_IMAGE_DIGEST to "Raw Image Digest", + ORIGINAL_RAW_FILE_DIGEST to "Original Raw File Digest", + SUB_TILE_BLOCK_SIZE to "Sub Tile Block Size", + ROW_INTERLEAVE_FACTOR to "Row Interleave Factor", + PROFILE_LOOK_TABLE_DIMS to "Profile Look Table Dims", + PROFILE_LOOK_TABLE_DATA to "Profile Look Table Data", + OPCODE_LIST_1 to "Opcode List 1", + OPCODE_LIST_2 to "Opcode List 2", + OPCODE_LIST_3 to "Opcode List 3", + NOISE_PROFILE to "Noise Profile", + ORIGINAL_DEFAULT_FINAL_SIZE to "Original Default Final Size", + ORIGINAL_BEST_QUALITY_FINAL_SIZE to "Original Best Quality Final Size", + ORIGINAL_DEFAULT_CROP_SIZE to "Original Default Crop Size", + PROFILE_HUE_SAT_MAP_ENCODING to "Profile Hue Sat Map Encoding", + PROFILE_LOOK_TABLE_ENCODING to "Profile Look Table Encoding", + BASELINE_EXPOSURE_OFFSET to "Baseline Exposure Offset", + DEFAULT_BLACK_RENDER to "Default Black Render", + NEW_RAW_IMAGE_DIGEST to "New Raw Image Digest", + RAW_TO_PREVIEW_GAIN to "Raw To Preview Gain", + DEFAULT_USER_CROP to "Default User Crop", + DEPTH_FORMAT to "Depth Format", + DEPTH_NEAR to "Depth Near", + DEPTH_FAR to "Depth Far", + DEPTH_UNITS to "Depth Units", + DEPTH_MEASURE_TYPE to "Depth Measure Type", + ENHANCE_PARAMS to "Enhance Params", + PROFILE_GAIN_TABLE_MAP to "Profile Gain Table Map", + SEMANTIC_NAME to "Semantic Name", + SEMANTIC_INSTANCE_ID to "Semantic Instance ID", + CALIBRATION_ILLUMINANT_3 to "Calibration Illuminant 3", + CAMERA_CALIBRATION_3 to "Camera Calibration 3", + COLOR_MATRIX_3 to "Color Matrix 3", + FORWARD_MATRIX_3 to "Forward Matrix 3", + ILLUMINANT_DATA_1 to "Illuminant Data 1", + ILLUMINANT_DATA_2 to "Illuminant Data 2", + ILLUMINANT_DATA_3 to "Illuminant Data 3", + MASK_SUB_AREA to "Mask Sub Area", + PROFILE_HUE_SAT_MAP_DATA_3 to "Profile Hue Sat Map Data 3", + REDUCTION_MATRIX_3 to "Reduction Matrix 3", + RGB_TABLES to "RGB Tables", + ) + + val tags = tagNameMap.keys +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 2fc085469..7b6317506 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -363,13 +363,17 @@ object ExifInterfaceHelper { } } - fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) { + fun ExifInterface.getSafeDateMillis(tag: String, subSecTag: String?, save: (value: Long) -> Unit) { if (this.hasAttribute(tag)) { val dateString = this.getAttribute(tag) if (dateString != null) { try { DATETIME_FORMAT.parse(dateString)?.let { date -> - save(date.time) + var dateMillis = date.time + if (subSecTag != null && this.hasAttribute(subSecTag)) { + dateMillis += Metadata.parseSubSecond(this.getAttribute(subSecTag)) + } + save(dateMillis) } } catch (e: ParseException) { Log.w(LOG_TAG, "failed to parse date=$dateString", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt index 4dbe77450..ddcf16ebd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt @@ -1,155 +1,55 @@ package deckers.thibault.aves.metadata -// Exif tags missing from `metadata-extractor` +/* +Exif tags missing from `metadata-extractor` + +Photoshop +https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ +https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf + */ object ExifTags { - // XPosition - // Tag = 286 (011E.H) private const val TAG_X_POSITION = 0x011e - - // YPosition - // Tag = 287 (011F.H) private const val TAG_Y_POSITION = 0x011f - - // ColorMap - // Tag = 320 (0140.H) + private const val TAG_T4_OPTIONS = 0x0124 + private const val TAG_T6_OPTIONS = 0x0125 private const val TAG_COLOR_MAP = 0x0140 - - // ExtraSamples - // Tag = 338 (0152.H) - // values: - // EXTRASAMPLE_UNSPECIFIED 0 // unspecified data - // EXTRASAMPLE_ASSOCALPHA 1 // associated alpha data - // EXTRASAMPLE_UNASSALPHA 2 // unassociated alpha data private const val TAG_EXTRA_SAMPLES = 0x0152 - - // SampleFormat - // Tag = 339 (0153.H) - // values: - // SAMPLEFORMAT_UINT 1 // unsigned integer data - // SAMPLEFORMAT_INT 2 // signed integer data - // SAMPLEFORMAT_IEEEFP 3 // IEEE floating point data - // SAMPLEFORMAT_VOID 4 // untyped data - // SAMPLEFORMAT_COMPLEXINT 5 // complex signed int - // SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating private const val TAG_SAMPLE_FORMAT = 0x0153 - - - // Rating tag used by Windows, value in percent - // Tag = 18249 (4749.H) - // Type = SHORT private const val TAG_RATING_PERCENT = 0x4749 - - /* - SGI - tags 32995-32999 - */ - - // Matteing - // Tag = 32995 (80E3.H) - // obsoleted by the 6.0 ExtraSamples (338) + private const val SONY_RAW_FILE_TYPE = 0x7000 + private const val SONY_TONE_CURVE = 0x7010 private const val TAG_MATTEING = 0x80e3 - /* - GeoTIFF - */ - - // ModelPixelScaleTag (optional) - // Tag = 33550 (830E.H) - // Type = DOUBLE - // Count = 3 - const val TAG_MODEL_PIXEL_SCALE = 0x830e - - // ModelTiepointTag (conditional) - // Tag = 33922 (8482.H) - // Type = DOUBLE - // Count = 6*K, K = number of tiepoints - const val TAG_MODEL_TIEPOINT = 0x8482 - - // ModelTransformationTag (conditional) - // Tag = 34264 (85D8.H) - // Type = DOUBLE - // Count = 16 - const val TAG_MODEL_TRANSFORMATION = 0x85d8 - - // GeoKeyDirectoryTag (mandatory) - // Tag = 34735 (87AF.H) - // Type = UNSIGNED SHORT - // Count = variable, >= 4 - const val TAG_GEO_KEY_DIRECTORY = 0x87af - - // GeoDoubleParamsTag (optional) - // Tag = 34736 (87BO.H) - // Type = DOUBLE - // Count = variable - private const val TAG_GEO_DOUBLE_PARAMS = 0x87b0 - - // GeoAsciiParamsTag (optional) - // Tag = 34737 (87B1.H) - // Type = ASCII - // Count = variable - private const val TAG_GEO_ASCII_PARAMS = 0x87b1 - - /* - Photoshop - https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ - https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf - */ - - // ImageSourceData - // Tag = 37724 (935C.H) - // Type = UNDEFINED + // sensing method (0x9217) redundant with sensing method (0xA217) + private const val TAG_SENSING_METHOD = 0x9217 private const val TAG_IMAGE_SOURCE_DATA = 0x935c - - /* - DNG - https://www.adobe.com/content/dam/acom/en/products/photoshop/pdfs/dng_spec_1.4.0.0.pdf - */ - - // CameraSerialNumber - // Tag = 50735 (C62F.H) - // Type = ASCII - // Count = variable - private const val TAG_CAMERA_SERIAL_NUMBER = 0xc62f - - // OriginalRawFileName (optional) - // Tag = 50827 (C68B.H) - // Type = ASCII or BYTE - // Count = variable - private const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b - - private val geotiffTags = listOf( - TAG_GEO_ASCII_PARAMS, - TAG_GEO_DOUBLE_PARAMS, - TAG_GEO_KEY_DIRECTORY, - TAG_MODEL_PIXEL_SCALE, - TAG_MODEL_TIEPOINT, - TAG_MODEL_TRANSFORMATION, - ) + private const val TAG_GDAL_METADATA = 0xa480 + private const val TAG_GDAL_NO_DATA = 0xa481 private val tagNameMap = hashMapOf( TAG_X_POSITION to "X Position", TAG_Y_POSITION to "Y Position", + TAG_T4_OPTIONS to "T4 Options", + TAG_T6_OPTIONS to "T6 Options", TAG_COLOR_MAP to "Color Map", TAG_EXTRA_SAMPLES to "Extra Samples", TAG_SAMPLE_FORMAT to "Sample Format", TAG_RATING_PERCENT to "Rating Percent", - // SGI + SONY_RAW_FILE_TYPE to "Sony Raw File Type", + SONY_TONE_CURVE to "Sony Tone Curve", TAG_MATTEING to "Matteing", - // GeoTIFF - TAG_GEO_ASCII_PARAMS to "Geo Ascii Params", - TAG_GEO_DOUBLE_PARAMS to "Geo Double Params", - TAG_GEO_KEY_DIRECTORY to "Geo Key Directory", - TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale", - TAG_MODEL_TIEPOINT to "Model Tiepoint", - TAG_MODEL_TRANSFORMATION to "Model Transformation", - // Photoshop + TAG_SENSING_METHOD to "Sensing Method (0x9217)", TAG_IMAGE_SOURCE_DATA to "Image Source Data", - // DNG - TAG_CAMERA_SERIAL_NUMBER to "Camera Serial Number", - TAG_ORIGINAL_RAW_FILE_NAME to "Original Raw File Name", - ) + TAG_GDAL_METADATA to "GDAL Metadata", + TAG_GDAL_NO_DATA to "GDAL No Data", + ).apply { + putAll(DngTags.tagNameMap) + putAll(GeoTiffTags.tagNameMap) + } + + fun isDngTag(tag: Int) = DngTags.tags.contains(tag) - fun isGeoTiffTag(tag: Int) = geotiffTags.contains(tag) + fun isGeoTiffTag(tag: Int) = GeoTiffTags.tags.contains(tag) fun getTagName(tag: Int): String? { return tagNameMap[tag] diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt new file mode 100644 index 000000000..208165fc3 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/GeoTiffTags.kt @@ -0,0 +1,50 @@ +package deckers.thibault.aves.metadata + +object GeoTiffTags { + // ModelPixelScaleTag (optional) + // Tag = 33550 (830E.H) + // Type = DOUBLE + // Count = 3 + const val TAG_MODEL_PIXEL_SCALE = 0x830e + + // ModelTiepointTag (conditional) + // Tag = 33922 (8482.H) + // Type = DOUBLE + // Count = 6*K, K = number of tiepoints + const val TAG_MODEL_TIEPOINT = 0x8482 + + // ModelTransformationTag (conditional) + // Tag = 34264 (85D8.H) + // Type = DOUBLE + // Count = 16 + const val TAG_MODEL_TRANSFORMATION = 0x85d8 + + // GeoKeyDirectoryTag (mandatory) + // Tag = 34735 (87AF.H) + // Type = UNSIGNED SHORT + // Count = variable, >= 4 + const val TAG_GEO_KEY_DIRECTORY = 0x87af + + // GeoDoubleParamsTag (optional) + // Tag = 34736 (87BO.H) + // Type = DOUBLE + // Count = variable + private const val TAG_GEO_DOUBLE_PARAMS = 0x87b0 + + // GeoAsciiParamsTag (optional) + // Tag = 34737 (87B1.H) + // Type = ASCII + // Count = variable + private const val TAG_GEO_ASCII_PARAMS = 0x87b1 + + val tagNameMap = hashMapOf( + TAG_GEO_ASCII_PARAMS to "Geo Ascii Params", + TAG_GEO_DOUBLE_PARAMS to "Geo Double Params", + TAG_GEO_KEY_DIRECTORY to "Geo Key Directory", + TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale", + TAG_MODEL_TIEPOINT to "Model Tiepoint", + TAG_MODEL_TRANSFORMATION to "Model Transformation", + ) + + val tags = tagNameMap.keys +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 8e86a124e..805b7e3df 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -65,6 +65,20 @@ object Metadata { } } + fun parseSubSecond(subSecond: String?): Int { + if (subSecond != null) { + try { + val millis = (".$subSecond".toDouble() * 1000).toInt() + if (millis in 0..999) { + return millis + } + } catch (e: NumberFormatException) { + // ignore + } + } + return 0 + } + // not sure which standards are used for the different video formats, // but looks like some form of ISO 8601 `basic format`: // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? @@ -96,18 +110,7 @@ object Metadata { null } ?: return 0 - var dateMillis = date.time - if (subSecond != null) { - try { - val millis = (".$subSecond".toDouble() * 1000).toInt() - if (millis in 0..999) { - dateMillis += millis.toLong() - } - } catch (e: NumberFormatException) { - // ignore - } - } - return dateMillis + return date.time + parseSubSecond(subSecond) } // opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 61b17d9c9..8a244d3d0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -6,7 +6,9 @@ import com.drew.lang.Rational import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory import com.drew.metadata.exif.ExifDirectoryBase +import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifReader +import com.drew.metadata.exif.ExifSubIFDDirectory import com.drew.metadata.iptc.IptcReader import com.drew.metadata.png.PngDirectory import deckers.thibault.aves.utils.LogUtils @@ -53,11 +55,34 @@ object MetadataExtractorHelper { if (this.containsTag(tag)) save(this.getRational(tag)) } - fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { + fun Directory.getSafeDateMillis(tag: Int, subSecond: String?): Long? { if (this.containsTag(tag)) { - val date = this.getDate(tag, null, TimeZone.getDefault()) - if (date != null) save(date.time) + val date = this.getDate(tag, subSecond, TimeZone.getDefault()) + if (date != null) return date.time } + return null + } + + // time tag and sub-second tag are *not* in the same directory + fun ExifSubIFDDirectory.getDateModifiedMillis(save: (value: Long) -> Unit) { + val parent = parent + if (parent is ExifIFD0Directory) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME) + val dateMillis = parent.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, subSecond) + if (dateMillis != null) save(dateMillis) + } + } + + fun ExifSubIFDDirectory.getDateDigitizedMillis(save: (value: Long) -> Unit) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME_DIGITIZED) + val dateMillis = this.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED, subSecond) + if (dateMillis != null) save(dateMillis) + } + + fun ExifSubIFDDirectory.getDateOriginalMillis(save: (value: Long) -> Unit) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME_ORIGINAL) + val dateMillis = this.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, subSecond) + if (dateMillis != null) save(dateMillis) } // geotiff @@ -69,13 +94,13 @@ object MetadataExtractorHelper { - If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included. */ fun ExifDirectoryBase.isGeoTiff(): Boolean { - if (!this.containsTag(ExifTags.TAG_GEO_KEY_DIRECTORY)) return false + if (!this.containsTag(GeoTiffTags.TAG_GEO_KEY_DIRECTORY)) return false - val modelTiepoint = this.containsTag(ExifTags.TAG_MODEL_TIEPOINT) - val modelTransformation = this.containsTag(ExifTags.TAG_MODEL_TRANSFORMATION) + val modelTiepoint = this.containsTag(GeoTiffTags.TAG_MODEL_TIEPOINT) + val modelTransformation = this.containsTag(GeoTiffTags.TAG_MODEL_TRANSFORMATION) if (!modelTiepoint && !modelTransformation) return false - val modelPixelScale = this.containsTag(ExifTags.TAG_MODEL_PIXEL_SCALE) + val modelPixelScale = this.containsTag(GeoTiffTags.TAG_MODEL_PIXEL_SCALE) if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false return true diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index b4ffd9b43..6eb90a59f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -185,7 +185,7 @@ class SourceEntry { dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it } dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) { height = it } dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { sourceRotationDegrees = getRotationDegreesForExifCode(it) } - dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it } + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { sourceDateTakenMillis = it } } // dimensions reported in EXIF do not always match the image @@ -218,7 +218,7 @@ class SourceEntry { exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } exif.getSafeInt(ExifInterface.TAG_IMAGE_LENGTH, acceptZero = false) { height = it } exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { sourceRotationDegrees = exif.rotationDegrees } - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME, ExifInterface.TAG_SUBSEC_TIME) { sourceDateTakenMillis = it } } } catch (e: Exception) { // ExifInterface initialization can fail with a RuntimeException diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 82bbdcf2e..0c25fc37f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -62,8 +62,7 @@ abstract class ImageProvider { open suspend fun renameMultiple( activity: Activity, - newFileName: String, - entries: List, + entriesToNewName: Map, isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { @@ -143,7 +142,7 @@ abstract class ImageProvider { var desiredNameWithoutExtension = if (sourceEntry.path != null) { val sourceFileName = File(sourceEntry.path).name - sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") + sourceFileName.substringBeforeLast(".") } else { sourceUri.lastPathSegment!! } @@ -757,7 +756,13 @@ abstract class ImageProvider { ExifInterface.TAG_DATETIME_DIGITIZED, ).forEach { field -> if (fields.contains(field)) { - exif.getSafeDateMillis(field) { date -> + val subSecTag = when (field) { + ExifInterface.TAG_DATETIME -> ExifInterface.TAG_SUBSEC_TIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifInterface.TAG_SUBSEC_TIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifInterface.TAG_SUBSEC_TIME_ORIGINAL + else -> null + } + exif.getSafeDateMillis(field, subSecTag) { date -> exif.setAttribute(field, ExifInterfaceHelper.DATETIME_FORMAT.format(date + shiftMillis)) } } @@ -964,8 +969,6 @@ abstract class ImageProvider { companion object { private val LOG_TAG = LogUtils.createTag() - val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$") - val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) // used when skipping a move/creation op because the target file already exists diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 4708e909d..f6eeb0242 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -191,7 +191,6 @@ class MediaStoreImageProvider : ImageProvider() { val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH) val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) - val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE) val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) @@ -229,7 +228,6 @@ class MediaStoreImageProvider : ImageProvider() { "height" to height, "sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, "sizeBytes" to cursor.getLong(sizeColumn), - "title" to cursor.getString(titleColumn), "dateModifiedSecs" to dateModifiedSecs, "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, "durationMillis" to durationMillis, @@ -489,7 +487,7 @@ class MediaStoreImageProvider : ImageProvider() { return skippedFieldMap } - val desiredNameWithoutExtension = desiredName.replaceFirst(FILE_EXTENSION_PATTERN, "") + val desiredNameWithoutExtension = desiredName.substringBeforeLast(".") val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( activity = activity, dir = targetDir, @@ -587,12 +585,14 @@ class MediaStoreImageProvider : ImageProvider() { override suspend fun renameMultiple( activity: Activity, - newFileName: String, - entries: List, + entriesToNewName: Map, isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { - for (entry in entries) { + for (kv in entriesToNewName) { + val entry = kv.key + val desiredName = kv.value + val sourceUri = entry.uri val sourcePath = entry.path val mimeType = entry.mimeType @@ -602,19 +602,20 @@ class MediaStoreImageProvider : ImageProvider() { "success" to false, ) - if (sourcePath != null) { + // prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store + if (sourcePath != null && !desiredName.startsWith('.')) { try { val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle( activity = activity, mimeType = mimeType, oldMediaUri = sourceUri, oldPath = sourcePath, - newFileName = newFileName, + desiredName = desiredName, ) result["newFields"] = newFields result["success"] = true } catch (e: Exception) { - Log.w(LOG_TAG, "failed to rename to newFileName=$newFileName entry with sourcePath=$sourcePath", e) + Log.w(LOG_TAG, "failed to rename to newFileName=$desiredName entry with sourcePath=$sourcePath", e) } } callback.onSuccess(result) @@ -626,10 +627,24 @@ class MediaStoreImageProvider : ImageProvider() { mimeType: String, oldMediaUri: Uri, oldPath: String, - newFileName: String, + desiredName: String, ): FieldMap { + val desiredNameWithoutExtension = desiredName.substringBeforeLast(".") + val oldFile = File(oldPath) - val newFile = File(oldFile.parent, newFileName) + if (oldFile.nameWithoutExtension == desiredNameWithoutExtension) return skippedFieldMap + + val dir = oldFile.parent ?: return skippedFieldMap + val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + activity = activity, + dir = dir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + mimeType = mimeType, + conflictStrategy = NameConflictStrategy.RENAME, + ) ?: return skippedFieldMap + val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" + + val newFile = File(dir, targetFileName) return when { oldFile == newFile -> skippedFieldMap StorageUtils.canEditByFile(activity, oldPath) -> renameSingleByFile(activity, mimeType, oldMediaUri, oldPath, newFile) @@ -681,8 +696,11 @@ class MediaStoreImageProvider : ImageProvider() { newFile: File ): FieldMap { Log.d(LOG_TAG, "rename document at uri=$oldMediaUri path=$oldPath") + val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri) + df ?: throw Exception("failed to get document at path=$oldPath") + @Suppress("BlockingMethodInNonBlockingContext") - val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false + val renamed = df.renameTo(newFile.name) if (!renamed) { throw Exception("failed to rename document at path=$oldPath") } @@ -763,8 +781,6 @@ class MediaStoreImageProvider : ImageProvider() { // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store val projection = arrayOf( MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.TITLE, ) try { val cursor = context.contentResolver.query(uri, projection, null, null, null) @@ -774,8 +790,6 @@ class MediaStoreImageProvider : ImageProvider() { newFields["contentId"] = uri.tryParseId() newFields["path"] = path cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) } - cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) } cursor.close() return newFields } @@ -846,8 +860,6 @@ class MediaStoreImageProvider : ImageProvider() { MediaColumns.PATH, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.SIZE, - // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? - MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT, MediaStore.MediaColumns.DATE_MODIFIED, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 15d64b552..fa810a2b4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -26,7 +26,7 @@ object MimeTypes { private const val CR2 = "image/x-canon-cr2" private const val CRW = "image/x-canon-crw" private const val DCR = "image/x-kodak-dcr" - private const val DNG = "image/x-adobe-dng" + const val DNG = "image/x-adobe-dng" private const val ERF = "image/x-epson-erf" private const val K25 = "image/x-kodak-k25" private const val KDC = "image/x-kodak-kdc" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index a420fb1f3..b2980e11b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -36,13 +36,14 @@ object StorageUtils { const val TRASH_PATH_PLACEHOLDER = "#trash" private fun isAppFile(context: Context, path: String): Boolean { - return context.getExternalFilesDirs(null).any { filesDir -> path.startsWith(filesDir.path) } + val filesDirs = context.getExternalFilesDirs(null).filterNotNull() + return filesDirs.any { path.startsWith(it.path) } } private fun appExternalFilesDirFor(context: Context, path: String): File? { - val filesDirs = context.getExternalFilesDirs(null) + val filesDirs = context.getExternalFilesDirs(null).filterNotNull() val volumePath = getVolumePath(context, path) - return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.first() + return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.firstOrNull() } fun trashDirFor(context: Context, path: String): File? { @@ -115,6 +116,15 @@ object StorageUtils { } private fun findPrimaryVolumePath(context: Context): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + val path = sm?.primaryStorageVolume?.directory?.path + if (path != null) { + return ensureTrailingSeparator(path) + } + } + + // fallback try { // we want: // /storage/emulated/0/ @@ -130,9 +140,16 @@ object StorageUtils { } private fun findVolumePaths(context: Context): Array { - // Final set of paths - val paths = HashSet() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + val paths = sm?.storageVolumes?.mapNotNull { it.directory?.path } + if (paths != null) { + return paths.map(::ensureTrailingSeparator).toTypedArray() + } + } + // fallback + val paths = HashSet() try { // Primary emulated SD-CARD val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: "" @@ -143,7 +160,8 @@ object StorageUtils { var validFiles: Boolean do { // `getExternalFilesDirs` sometimes include `null` when called right after getting read access - // (e.g. on API 30 emulator) so we retry until the file system is ready + // (e.g. on API 30 emulator) so we retry until the file system is ready. + // TODO TLAD It can also include `null` when there is a faulty SD card. val externalFilesDirs = context.getExternalFilesDirs(null) validFiles = !externalFilesDirs.contains(null) if (validFiles) { diff --git a/android/build.gradle b/android/build.gradle index 4174edca7..b58087225 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.1' + classpath 'com.android.tools.build:gradle:7.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // GMS & Firebase Crashlytics are not actually used by all flavors classpath 'com.google.gms:google-services:4.3.10' diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/1.png b/fastlane/metadata/android/de/images/phoneScreenshots/1.png index 0ef48db3d..149abe06e 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/1.png and b/fastlane/metadata/android/de/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/2.png b/fastlane/metadata/android/de/images/phoneScreenshots/2.png index fb5080556..1df869df0 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/2.png and b/fastlane/metadata/android/de/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/3.png b/fastlane/metadata/android/de/images/phoneScreenshots/3.png index 540e6c430..b1dca7f8d 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/3.png and b/fastlane/metadata/android/de/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/4.png b/fastlane/metadata/android/de/images/phoneScreenshots/4.png index 674ee85e7..73b826187 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/4.png and b/fastlane/metadata/android/de/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/5.png b/fastlane/metadata/android/de/images/phoneScreenshots/5.png index 19bfe9bcd..74bc802fa 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/5.png and b/fastlane/metadata/android/de/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/6.png b/fastlane/metadata/android/de/images/phoneScreenshots/6.png index 2e8d3034d..56099d117 100644 Binary files a/fastlane/metadata/android/de/images/phoneScreenshots/6.png and b/fastlane/metadata/android/de/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/changelogs/1069.txt b/fastlane/metadata/android/en-US/changelogs/1069.txt new file mode 100644 index 000000000..93bf3bf8b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1069.txt @@ -0,0 +1,4 @@ +In v1.6.3: +- enjoy the light theme +- rename items in bulk +Full changelog available on GitHub diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index aef538c60..bf177de91 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index a6d84ce73..cab24996a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index 039a7a267..96767e884 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index 42a897ce9..5aa80520d 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 7fc82a805..763fab5f2 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index e93411a46..f2e09ae08 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png index aedd59b04..4480d45e4 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png index 907ea242f..94170fd16 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png index fff50ac57..7e6ed547e 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png index 69a09e0b3..6d3fdb80f 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png index 77cbdf3b5..236a70926 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png index 1cb5582b6..cc9d9e252 100644 Binary files a/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/1.png b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png index 4e9d3a559..c139324b1 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/1.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/2.png b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png index d6d30754d..47c0852ce 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/2.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/3.png b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png index d6d1136a2..d1d9df6a8 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/3.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/4.png b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png index 1e1b71624..792d081af 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/4.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/5.png b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png index 82e97daf0..1083c6be4 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/5.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/6.png b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png index dcc9e4eff..077def362 100644 Binary files a/fastlane/metadata/android/fr/images/phoneScreenshots/6.png and b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/1.png b/fastlane/metadata/android/id/images/phoneScreenshots/1.png index 1148786fd..f157c0be5 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/1.png and b/fastlane/metadata/android/id/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/2.png b/fastlane/metadata/android/id/images/phoneScreenshots/2.png index f1a6c95bb..25db2f7d4 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/2.png and b/fastlane/metadata/android/id/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/3.png b/fastlane/metadata/android/id/images/phoneScreenshots/3.png index 646a107e9..c5f809a92 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/3.png and b/fastlane/metadata/android/id/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/4.png b/fastlane/metadata/android/id/images/phoneScreenshots/4.png index 887f67554..022e506a5 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/4.png and b/fastlane/metadata/android/id/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/5.png b/fastlane/metadata/android/id/images/phoneScreenshots/5.png index 5684e687b..78286da77 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/5.png and b/fastlane/metadata/android/id/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/id/images/phoneScreenshots/6.png b/fastlane/metadata/android/id/images/phoneScreenshots/6.png index 43ed58d3e..75ba57bca 100644 Binary files a/fastlane/metadata/android/id/images/phoneScreenshots/6.png and b/fastlane/metadata/android/id/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/1.png b/fastlane/metadata/android/ja/images/phoneScreenshots/1.png index 8a2eb8f99..b7f99f9c7 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/2.png b/fastlane/metadata/android/ja/images/phoneScreenshots/2.png index e840f74b2..8b4364e04 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/3.png b/fastlane/metadata/android/ja/images/phoneScreenshots/3.png index a72c431f3..e8b879179 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/4.png b/fastlane/metadata/android/ja/images/phoneScreenshots/4.png index e47324358..fd39d1ef1 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/5.png b/fastlane/metadata/android/ja/images/phoneScreenshots/5.png index 2c0a91d4f..39b6769ea 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/ja/images/phoneScreenshots/6.png b/fastlane/metadata/android/ja/images/phoneScreenshots/6.png index 392691f0e..d358bb072 100644 Binary files a/fastlane/metadata/android/ja/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ja/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/1.png b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png index d8e080ffc..ba194324a 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/2.png b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png index d6928ee42..a601e74d5 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/3.png b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png index 7906b78cf..8206e30b9 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/4.png b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png index 5ef843272..a089a9252 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/5.png b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png index b1c3362c9..206888b33 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/6.png b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png index a733c7331..5ad1622f3 100644 Binary files a/fastlane/metadata/android/ko/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png index 5721e71de..560bcfa3a 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png index 41555870c..e8abe4129 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png index b8bd3594f..b28935bfc 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png index 364cc6fbc..0e8c26458 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png index c75e08755..172a48660 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png index 5244a840b..372ddca85 100644 Binary files a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/1.png b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png index f1315be49..2facdbc61 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/1.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png index 7a310ca4f..ca1770023 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png index 30ad8972d..ac3b92614 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/4.png b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png index e91ec53c5..4a54f9f2e 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/4.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/5.png b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png index e7b6dc26b..38cb374c4 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/5.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/6.png b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png index b69e24c5c..f14b4f9bc 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/6.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png differ diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 633b13c3b..1c49c6928 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -137,6 +137,13 @@ "accessibilityAnimationsRemove": "Verhinderung von Bildschirmeffekten", "accessibilityAnimationsKeep": "Bildschirmeffekte beibehalten", + "displayRefreshRatePreferHighest": "Höchste Rate", + "displayRefreshRatePreferLowest": "Niedrigste Rate", + + "themeBrightnessLight": "Hell", + "themeBrightnessDark": "Dunkel", + "themeBrightnessBlack": "Schwarz", + "albumTierNew": "Neu", "albumTierPinned": "Angeheftet", "albumTierSpecial": "Häufig verwendet", @@ -170,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Dieses Element in den Papierkorb verschieben?} other{Diese {count} Elemente in den Papierkorb verschieben?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Element gelöscht werden soll?} other{Sicher, dass diese {count} Elemente gelöscht werden sollen?}}", + "moveUndatedConfirmationDialogMessage": "Einige Artikel haben kein Metadaten-Datum. Ihr aktuelles Datum wird durch diesen Vorgang zurückgesetzt, es sei denn, es wurde ein Metadaten-Datum festgelegt.", + "moveUndatedConfirmationDialogSetDate": "Datum einstellen", "videoResumeDialogMessage": "Soll bei {time} weiter abspielt werden?", "videoStartOverButtonLabel": "NEU BEGINNEN", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "Neuer Name", "renameAlbumDialogLabelAlreadyExistsHelper": "Verzeichnis existiert bereits", + "renameEntrySetPageTitle": "Umbenennen", + "renameEntrySetPagePatternFieldLabel": "Benennungsmuster", + "renameEntrySetPageInsertTooltip": "Feld einfügen", + "renameEntrySetPagePreview": "Vorschau", + + "renameProcessorCounter": "Zähler", + "renameProcessorName": "Name", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Album und der Inhalt gelöscht werden soll?} other{Sicher, dass dieses Album und deren {count} Elemente gelöscht werden sollen?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Sicher, dass diese Alben und deren Inhalt gelöscht werden sollen?} other{Sicher, dass diese Alben und deren {count} Elemente gelöscht werden sollen?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "Datum & Uhrzeit", "editEntryDateDialogSetCustom": "Datum einstellen", "editEntryDateDialogCopyField": "Von anderem Datum kopieren", + "editEntryDateDialogCopyItem": "Von einem anderen Element kopieren", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", "editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, =1{1 Element konnte nicht gelöscht werden} other{{count} Elemente konnten nicht gelöscht werden}}", "collectionCopyFailureFeedback": "{count, plural, =1{1 Element konnte nicht kopiert werden} other{{count} Element konnten nicht kopiert werden}}", "collectionMoveFailureFeedback": "{count, plural, =1{1 Element konnte nicht verschoben werden} other{{count} Elemente konnten nicht verschoben werden}}", + "collectionRenameFailureFeedback": "{count, plural, =1{Fehler beim Umbenennen eines Elements} other{Fehler beim Umbenennen {count} Elemente}}", "collectionEditFailureFeedback": "{count, plural, =1{1 Element konnte nicht bearbeitet werden} other{{count} 1 Elemente konnten nicht bearbeitet werden}}", "collectionExportFailureFeedback": "{count, plural, =1{1 Seite konnte nicht exportiert werden} other{{count} Seiten konnten nicht exportiert werden}}", "collectionCopySuccessFeedback": "{count, plural, =1{1 Element kopier} other{ {count} Elemente kopiert}}", "collectionMoveSuccessFeedback": "{count, plural, =1{1 Element verschoben} other{{count} Elemente verschoben}}", + "collectionRenameSuccessFeedback": "{count, plural, =1{1 Element unmebannt} other{{count} Elemente umbenannt}}", "collectionEditSuccessFeedback": "{count, plural, =1{1 Element bearbeitet} other{ {count} Elemente bearbeitet}}", "collectionEmptyFavourites": "Keine Favoriten", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "Bestätigungsdialoge", "settingsConfirmationDialogDeleteItems": "Vor dem endgültigen Löschen von Elementen fragen", "settingsConfirmationDialogMoveToBinItems": "Vor dem Verschieben von Elementen in den Papierkorb fragen", + "settingsConfirmationDialogMoveUndatedItems": "Vor Verschiebung von Objekten ohne Metadaten-Datum fragen", "settingsNavigationDrawerTile": "Menü Navigation", "settingsNavigationDrawerEditorTitle": "Menü Navigation", @@ -501,6 +522,12 @@ "settingsTimeToTakeActionTile": "Zeit zum Reagieren", "settingsTimeToTakeActionTitle": "Zeit zum Reagieren", + "settingsSectionDisplay": "Anzeige", + "settingsThemeBrightness": "Thema", + "settingsThemeColorHighlights": "Farbige Highlights", + "settingsDisplayRefreshRateModeTile": "Bildwiederholrate der Anzeige", + "settingsDisplayRefreshRateModeTitle": "Bildwiederholrate", + "settingsSectionLanguage": "Sprache & Formate", "settingsLanguage": "Sprache", "settingsCoordinateFormatTile": "Koordinatenformat", @@ -570,4 +597,4 @@ "filePickerOpenFrom": "Öffnen von", "filePickerNoItems": "Keine Elemente", "filePickerUseThisFolder": "Diesen Ordner verwenden" -} \ No newline at end of file +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bfcd0e5f5..f5d38548b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -177,6 +177,13 @@ "accessibilityAnimationsRemove": "Prevent screen effects", "accessibilityAnimationsKeep": "Keep screen effects", + "displayRefreshRatePreferHighest": "Highest rate", + "displayRefreshRatePreferLowest": "Lowest rate", + + "themeBrightnessLight": "Light", + "themeBrightnessDark": "Dark", + "themeBrightnessBlack": "Black", + "albumTierNew": "New", "albumTierPinned": "Pinned", "albumTierSpecial": "Common", @@ -282,6 +289,8 @@ "count": {} } }, + "moveUndatedConfirmationDialogMessage": "Save item dates before proceeding?", + "moveUndatedConfirmationDialogSetDate": "Save dates", "videoResumeDialogMessage": "Do you want to resume playing at {time}?", "@videoResumeDialogMessage": { @@ -309,6 +318,14 @@ "renameAlbumDialogLabel": "New name", "renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", + "renameEntrySetPageTitle": "Rename", + "renameEntrySetPagePatternFieldLabel": "Naming pattern", + "renameEntrySetPageInsertTooltip": "Insert field", + "renameEntrySetPagePreview": "Preview", + + "renameProcessorCounter": "Counter", + "renameProcessorName": "Name", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and its item?} other{Delete this album and its {count} items?}}", "@deleteSingleAlbumConfirmationDialogMessage": { "placeholders": { @@ -331,6 +348,7 @@ "editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogSetCustom": "Set custom date", "editEntryDateDialogCopyField": "Copy from other date", + "editEntryDateDialogCopyItem": "Copy from other item", "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", "editEntryDateDialogSourceFileModifiedDate": "File modified date", @@ -453,6 +471,12 @@ "count": {} } }, + "collectionRenameFailureFeedback": "{count, plural, =1{Failed to rename 1 item} other{Failed to rename {count} items}}", + "@collectionRenameFailureFeedback": { + "placeholders": { + "count": {} + } + }, "collectionEditFailureFeedback": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}", "@collectionEditFailureFeedback": { "placeholders": { @@ -477,6 +501,12 @@ "count": {} } }, + "collectionRenameSuccessFeedback": "{count, plural, =1{Renamed 1 item} other{Renamed {count} items}}", + "@collectionRenameSuccessFeedback": { + "placeholders": { + "count": {} + } + }, "collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}", "@collectionEditSuccessFeedback": { "placeholders": { @@ -563,6 +593,7 @@ "settingsConfirmationDialogTitle": "Confirmation Dialogs", "settingsConfirmationDialogDeleteItems": "Ask before deleting items forever", "settingsConfirmationDialogMoveToBinItems": "Ask before moving items to the recycle bin", + "settingsConfirmationDialogMoveUndatedItems": "Ask before moving undated items", "settingsNavigationDrawerTile": "Navigation menu", "settingsNavigationDrawerEditorTitle": "Navigation Menu", @@ -671,6 +702,12 @@ "settingsTimeToTakeActionTile": "Time to take action", "settingsTimeToTakeActionTitle": "Time to Take Action", + "settingsSectionDisplay": "Display", + "settingsThemeBrightness": "Theme", + "settingsThemeColorHighlights": "Color highlights", + "settingsDisplayRefreshRateModeTile": "Display refresh rate", + "settingsDisplayRefreshRateModeTitle": "Refresh Rate", + "settingsSectionLanguage": "Language & Formats", "settingsLanguage": "Language", "settingsCoordinateFormatTile": "Coordinate format", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 99e1ce283..15da9e8a3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -68,6 +68,8 @@ "entryActionRemoveFavourite": "Quitar de favoritos", "videoActionCaptureFrame": "Capturar fotograma", + "videoActionMute": "Silenciar", + "videoActionUnmute": "Dejar de silenciar", "videoActionPause": "Pausa", "videoActionPlay": "Reproducir", "videoActionReplay10": "Retroceder 10 segundos", @@ -135,6 +137,13 @@ "accessibilityAnimationsRemove": "Prevenir efectos en pantalla", "accessibilityAnimationsKeep": "Mantener efectos en pantalla", + "displayRefreshRatePreferHighest": "Alta tasa", + "displayRefreshRatePreferLowest": "Baja tasa", + + "themeBrightnessLight": "Claro", + "themeBrightnessDark": "Obscuro", + "themeBrightnessBlack": "Negro", + "albumTierNew": "Nuevo", "albumTierPinned": "Fijado", "albumTierSpecial": "Común", @@ -151,7 +160,6 @@ "restrictedAccessDialogMessage": "Esta aplicación no tiene permiso para modificar archivos de {directory} en «{volume}».\n\nPor favor use un gestor de archivos o la aplicación de galería preinstalada para mover los elementos a otro directorio.", "notEnoughSpaceDialogTitle": "Espacio insuficiente", "notEnoughSpaceDialogMessage": "Esta operación necesita {neededSize} de espacio libre en «{volume}» para completarse, pero sólo hay {freeSize} disponible.", - "missingSystemFilePickerDialogTitle": "Selector de archivos del sistema no disponible", "missingSystemFilePickerDialogMessage": "El selector de archivos del sistema no se encuentra disponible o fue deshabilitado. Por favor habilítelo e intente nuevamente.", @@ -168,8 +176,9 @@ "noMatchingAppDialogMessage": "No se encontraron aplicaciones para manejar esto.", "binEntriesConfirmationDialogMessage": "{count, plural, =1{¿Mover este elemento al cesto de basura?} other{¿Mover estos {count} elementos al cesto de basura?}}", - "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de borrar este elemento?} other{¿Está seguro de querer borrar {count} elementos?}}", + "moveUndatedConfirmationDialogMessage": "Algunos elementos no poseen fecha en sus metadatos. Su fecha actual será reemplazada por esta operación a menos que una fecha de metadatos sea fijada.", + "moveUndatedConfirmationDialogSetDate": "Fijar fecha", "videoResumeDialogMessage": "¿Desea reanudar la reproducción a las {time}?", "videoStartOverButtonLabel": "VOLVER A EMPEZAR", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "Renombrar", "renameAlbumDialogLabelAlreadyExistsHelper": "El directorio ya existe", + "renameEntrySetPageTitle": "Renombrar", + "renameEntrySetPagePatternFieldLabel": "Patrón de nombramiento", + "renameEntrySetPageInsertTooltip": "Insertar campo", + "renameEntrySetPagePreview": "Vista previa", + + "renameProcessorCounter": "Contador", + "renameProcessorName": "Nombre", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar este álbum y un elemento?} other{¿Está seguro de que desea borrar este álbum y sus {count} elementos?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar estos álbumes y un elemento?} other{¿Está seguro de que desea borrar estos álbumes y sus {count} elementos?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "Fecha y hora", "editEntryDateDialogSetCustom": "Establecer fecha personalizada", "editEntryDateDialogCopyField": "Copiar de otra fecha", + "editEntryDateDialogCopyItem": "Copiar de otro elemento", "editEntryDateDialogExtractFromTitle": "Extraer del título", "editEntryDateDialogShift": "Cambiar", "editEntryDateDialogSourceFileModifiedDate": "Fecha de modificación del archivo", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, =1{Error al borrar 1 elemento} other{Error al borrar {count} elementos}}", "collectionCopyFailureFeedback": "{count, plural, =1{Error al copiar 1 item} other{Error al copiar {count} elementos}}", "collectionMoveFailureFeedback": "{count, plural, =1{Error al mover 1 elemento} other{Error al mover {count} elementos}}", + "collectionRenameFailureFeedback": "{count, plural, =1{Error al renombrar 1 elemento} other{Error al renombrar {count} elementos}}", "collectionEditFailureFeedback": "{count, plural, =1{Error al editar 1 elemento} other{Error al editar {count} elementos}}", "collectionExportFailureFeedback": "{count, plural, =1{Error al exportar 1 página} other{Error al exportar {count} páginas}}", "collectionCopySuccessFeedback": "{count, plural, =1{1 elemento copiado} other{Copiados{count} elementos}}", "collectionMoveSuccessFeedback": "{count, plural, =1{1 elemento movido} other{Movidos {count} elementos}}", + "collectionRenameSuccessFeedback": "{count, plural, =1{1 elemento renombrado} other{Renombrados {count} elementos}}", "collectionEditSuccessFeedback": "{count, plural, =1{1 elemento editado} other{Editados {count} elementos}}", "collectionEmptyFavourites": "Sin favoritos", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "Diálogos de confirmación", "settingsConfirmationDialogDeleteItems": "Preguntar antes de eliminar elementos permanentemente", "settingsConfirmationDialogMoveToBinItems": "Preguntar antes de mover elementos al cesto de basura", + "settingsConfirmationDialogMoveUndatedItems": "Preguntar antes de mover elementos sin una fecha de metadatos", "settingsNavigationDrawerTile": "Menú de navegación", "settingsNavigationDrawerEditorTitle": "Menú de navegación", @@ -501,6 +522,12 @@ "settingsTimeToTakeActionTile": "Retraso para ejecutar una acción", "settingsTimeToTakeActionTitle": "Retraso para ejecutar una acción", + "settingsSectionDisplay": "Pantalla", + "settingsThemeBrightness": "Tema", + "settingsThemeColorHighlights": "Acentos de color", + "settingsDisplayRefreshRateModeTile": "Tasa de refresco de la pantalla", + "settingsDisplayRefreshRateModeTitle": "Tasa de refresco", + "settingsSectionLanguage": "Idioma y formatos", "settingsLanguage": "Idioma", "settingsCoordinateFormatTile": "Formato de coordenadas", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 1c9be0f58..70c61c831 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -137,6 +137,13 @@ "accessibilityAnimationsRemove": "Empêchez certains effets de l’écran", "accessibilityAnimationsKeep": "Conserver les effets de l’écran", + "displayRefreshRatePreferHighest": "Fréquence maximale", + "displayRefreshRatePreferLowest": "Fréquence minimale", + + "themeBrightnessLight": "Clair", + "themeBrightnessDark": "Sombre", + "themeBrightnessBlack": "Noir", + "albumTierNew": "Nouveaux", "albumTierPinned": "Épinglés", "albumTierSpecial": "Standards", @@ -170,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Mettre cet élément à la corbeille ?} other{Mettre ces {count} éléments à la corbeille ?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Supprimer cet élément ?} other{Supprimer ces {count} éléments ?}}", + "moveUndatedConfirmationDialogMessage": "Sauvegarder les dates des éléments avant de continuer?", + "moveUndatedConfirmationDialogSetDate": "Sauvegarder les dates", "videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?", "videoStartOverButtonLabel": "RECOMMENCER", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "Nouveau nom", "renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà", + "renameEntrySetPageTitle": "Renommage", + "renameEntrySetPagePatternFieldLabel": "Modèle de nommage", + "renameEntrySetPageInsertTooltip": "Ajouter un champ", + "renameEntrySetPagePreview": "Aperçu", + + "renameProcessorCounter": "Compteur", + "renameProcessorName": "Nom", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer cet album et son élément ?} other{Supprimer cet album et ses {count} éléments ?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer ces albums et leur élément ?} other{Supprimer ces albums et leurs {count} éléments ?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "Date & Heure", "editEntryDateDialogSetCustom": "Régler une date personnalisée", "editEntryDateDialogCopyField": "Copier d’une autre date", + "editEntryDateDialogCopyItem": "Copier d’un autre élément", "editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogShift": "Décaler", "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, =1{Échec de la suppression d’1 élément} other{Échec de la suppression de {count} éléments}}", "collectionCopyFailureFeedback": "{count, plural, =1{Échec de la copie d’1 élément} other{Échec de la copie de {count} éléments}}", "collectionMoveFailureFeedback": "{count, plural, =1{Échec du déplacement d’1 élément} other{Échec du déplacement de {count} éléments}}", + "collectionRenameFailureFeedback": "{count, plural, =1{Échec du renommage d’1 élément} other{Échec du renommage de {count} éléments}}", "collectionEditFailureFeedback": "{count, plural, =1{Échec de la modification d’1 élément} other{Échec de la modification de {count} éléments}}", "collectionExportFailureFeedback": "{count, plural, =1{Échec de l’export d’1 page} other{Échec de l’export de {count} pages}}", "collectionCopySuccessFeedback": "{count, plural, =1{1 élément copié} other{{count} éléments copiés}}", "collectionMoveSuccessFeedback": "{count, plural, =1{1 élément déplacé} other{{count} éléments déplacés}}", + "collectionRenameSuccessFeedback": "{count, plural, =1{1 élément renommé} other{{count} éléments renommés}}", "collectionEditSuccessFeedback": "{count, plural, =1{1 élément modifié} other{{count} éléments modifiés}}", "collectionEmptyFavourites": "Aucun favori", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "Demandes de confirmation", "settingsConfirmationDialogDeleteItems": "Suppression définitive d’éléments", "settingsConfirmationDialogMoveToBinItems": "Mise d’éléments à la corbeille", + "settingsConfirmationDialogMoveUndatedItems": "Déplacement d’éléments non datés", "settingsNavigationDrawerTile": "Menu de navigation", "settingsNavigationDrawerEditorTitle": "Menu de navigation", @@ -501,6 +522,12 @@ "settingsTimeToTakeActionTile": "Délai pour effectuer une action", "settingsTimeToTakeActionTitle": "Délai pour effectuer une action", + "settingsSectionDisplay": "Affichage", + "settingsThemeBrightness": "Thème", + "settingsThemeColorHighlights": "Surlignages colorés", + "settingsDisplayRefreshRateModeTile": "Fréquence d’actualisation de l'écran", + "settingsDisplayRefreshRateModeTitle": "Fréquence d’actualisation", + "settingsSectionLanguage": "Langue & Formats", "settingsLanguage": "Langue", "settingsCoordinateFormatTile": "Format de coordonnées", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 93a384738..7c848b5f5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -68,6 +68,8 @@ "entryActionRemoveFavourite": "Hapus dari favorit", "videoActionCaptureFrame": "Tangkap bingkai", + "videoActionMute": "Mute", + "videoActionUnmute": "Unmute", "videoActionPause": "Henti", "videoActionPlay": "Mainkan", "videoActionReplay10": "Mundurkan 10 detik", @@ -112,6 +114,11 @@ "videoLoopModeShortOnly": "Hanya video pendek", "videoLoopModeAlways": "Selalu", + "videoControlsPlay": "Putar", + "videoControlsPlaySeek": "Putar dan cari", + "videoControlsPlayOutside": "Buka dengan pemutar lain", + "videoControlsNone": "Tidak ada", + "mapStyleGoogleNormal": "Google Maps", "mapStyleGoogleHybrid": "Google Maps (Hybrid)", "mapStyleGoogleTerrain": "Google Maps (Terrain)", @@ -130,6 +137,13 @@ "accessibilityAnimationsRemove": "Mencegah efek layar", "accessibilityAnimationsKeep": "Simpan efek layar", + "displayRefreshRatePreferHighest": "Penyegaran tertinggi", + "displayRefreshRatePreferLowest": "Penyegaran terendah", + + "themeBrightnessLight": "Terang", + "themeBrightnessDark": "Gelap", + "themeBrightnessBlack": "Hitam", + "albumTierNew": "Baru", "albumTierPinned": "Disemat", "albumTierSpecial": "Umum", @@ -163,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Pindahkan benda ini ke tong sampah?} other{Pindahkan {count} benda ke tempat sampah?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus benda ini?} other{Apakah Anda yakin ingin menghapus {count} benda?}}", + "moveUndatedConfirmationDialogMessage": "Beberapa benda tidak mempunyai tanggal metadata. Tanggal mereka sekarang akan diatur ulang dengan operasi ini kecuali ada tanggal metadata yang ditetapkan.", + "moveUndatedConfirmationDialogSetDate": "Atur tanggal", "videoResumeDialogMessage": "Apakah Anda ingin melanjutkan di {time}?", "videoStartOverButtonLabel": "ULANG DARI AWAL", @@ -182,6 +198,14 @@ "renameAlbumDialogLabel": "Nama baru", "renameAlbumDialogLabelAlreadyExistsHelper": "Direktori sudah ada", + "renameEntrySetPageTitle": "Ganti nama", + "renameEntrySetPagePatternFieldLabel": "Pola penamaan", + "renameEntrySetPageInsertTooltip": "Masukkan bidang", + "renameEntrySetPagePreview": "Pratinjau", + + "renameProcessorCounter": "Menangkal", + "renameProcessorName": "Nama", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Yakin ingin menghapus album ini dan bendanya?} other{Anda yakin ingin menghapus album ini dan {count} bendanya?}}", @@ -194,6 +218,7 @@ "editEntryDateDialogTitle": "Tanggal & Waktu", "editEntryDateDialogSetCustom": "Atur tanggal khusus", "editEntryDateDialogCopyField": "Salin dari tanggal lain", + "editEntryDateDialogCopyItem": "Salin dari benda lain", "editEntryDateDialogExtractFromTitle": "Ekstrak dari judul", "editEntryDateDialogShift": "Geser", "editEntryDateDialogSourceFileModifiedDate": "Tanggal modifikasi file", @@ -301,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, other{Gagal untuk menghapus {count} benda}}", "collectionCopyFailureFeedback": "{count, plural, other{Gagal untuk menyalin {count} benda}}", "collectionMoveFailureFeedback": "{count, plural, other{Gagal untuk menggerakkan {count} benda}}", + "collectionRenameFailureFeedback": "{count, plural, other{Gagal untuk menggantikan nama {count} benda}}", "collectionEditFailureFeedback": "{count, plural, other{Gagal untuk mengubah {count} benda}}", "collectionExportFailureFeedback": "{count, plural, other{Gagal untuk mengekspor {count} halaman}}", "collectionCopySuccessFeedback": "{count, plural, other{Menyalin {count} benda}}", "collectionMoveSuccessFeedback": "{count, plural, other{{count} benda terpindah}}", + "collectionRenameSuccessFeedback": "{count, plural, other{Tergantikan nama untuk {count} benda}}", "collectionEditSuccessFeedback": "{count, plural, other{Mengubah {count} benda}}", "collectionEmptyFavourites": "Tidak ada favorit", @@ -386,6 +413,8 @@ "settingsConfirmationDialogTitle": "Dialog Konfirmasi", "settingsConfirmationDialogDeleteItems": "Tanya sebelum menghapus benda selamanya", "settingsConfirmationDialogMoveToBinItems": "Tanya sebelum memindahkan benda ke tong sampah", + "settingsConfirmationDialogMoveUndatedItems": "Tanyakan sebelum memindahkan barang tanpa metadata tanggal", + "settingsNavigationDrawerTile": "Menu navigasi", "settingsNavigationDrawerEditorTitle": "Menu Navigasi", "settingsNavigationDrawerBanner": "Sentuh dan tahan untuk memindahkan dan menyusun ulang benda menu.", @@ -429,6 +458,7 @@ "settingsViewerShowInformation": "Tampilkan informasi", "settingsViewerShowInformationSubtitle": "Tampilkan judul, tanggal, lokasi, dll.", "settingsViewerShowShootingDetails": "Tampilkan detail pemotretan", + "settingsViewerShowOverlayThumbnails": "Tampilkan thumbnail", "settingsViewerEnableOverlayBlurEffect": "Efek Kabur", "settingsVideoPageTitle": "Pengaturan Video", @@ -454,6 +484,13 @@ "settingsSubtitleThemeTextAlignmentCenter": "Tengah", "settingsSubtitleThemeTextAlignmentRight": "Kanan", + "settingsVideoControlsTile": "Kontrol", + "settingsVideoControlsTitle": "Kontrol", + "settingsVideoButtonsTile": "Tombol", + "settingsVideoButtonsTitle": "Tombol", + "settingsVideoGestureDoubleTapTogglePlay": "Ketuk dua kali untuk mainkan/hentikan", + "settingsVideoGestureSideDoubleTapSeek": "Ketuk dua kali di tepi layar untuk mencari kebelakang/kedepan", + "settingsSectionPrivacy": "Privasi", "settingsAllowInstalledAppAccess": "Izinkan akses ke inventori aplikasi", "settingsAllowInstalledAppAccessSubtitle": "Digunakan untuk meningkatkan tampilan album", @@ -485,6 +522,12 @@ "settingsTimeToTakeActionTile": "Waktu untuk mengambil tindakan", "settingsTimeToTakeActionTitle": "Saatnya Bertindak", + "settingsSectionDisplay": "Tampilan", + "settingsThemeBrightness": "Tema", + "settingsThemeColorHighlights": "Highlight warna", + "settingsDisplayRefreshRateModeTile": "Tingkat penyegaran tampilan", + "settingsDisplayRefreshRateModeTitle": "Tingkat Penyegaran", + "settingsSectionLanguage": "Bahasa & Format", "settingsLanguage": "Bahasa", "settingsCoordinateFormatTile": "Format koordinat", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index acc36340d..1b9d08226 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -137,6 +137,13 @@ "accessibilityAnimationsRemove": "画面エフェクトを利用しない", "accessibilityAnimationsKeep": "画面エフェクトを利用", + "displayRefreshRatePreferHighest": "高レート", + "displayRefreshRatePreferLowest": "低レート", + + "themeBrightnessLight": "ライト", + "themeBrightnessDark": "ダーク", + "themeBrightnessBlack": "黒", + "albumTierNew": "新規", "albumTierPinned": "固定", "albumTierSpecial": "全体", @@ -170,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{このアイテムをごみ箱に移動しますか?} other{{count} 件のアイテムをごみ箱に移動しますか?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{このアイテムを削除しますか?} other{{count} 件のアイテムを削除しますか?}}", + "moveUndatedConfirmationDialogMessage": "いくつかのアイテムはメタデータ上に日付がありません。メタデータ上の日付が設定されない場合、この操作によりこれらの現在の日付はリセットされます", + "moveUndatedConfirmationDialogSetDate": "日付を設定", "videoResumeDialogMessage": " {time} の時点から再生を再開しますか?", "videoStartOverButtonLabel": "最初から再生", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "新しい名前", "renameAlbumDialogLabelAlreadyExistsHelper": "ディレクトリが既に存在します", + "renameEntrySetPageTitle": "名前を変更", + "renameEntrySetPagePatternFieldLabel": "名前付けのパターン", + "renameEntrySetPageInsertTooltip": "フィールドを挿入", + "renameEntrySetPagePreview": "プレビュー", + + "renameProcessorCounter": "連番", + "renameProcessorName": "名前", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{このアルバムとアルバム内のアイテムを削除しますか?} other{このアルバムとアルバム内の {count} 件のアイテムを削除しますか?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{これらのアルバムとアルバム内のアイテムを削除しますか?} other{これらのアルバムとアルバム内の {count} 件のアイテムを削除しますか?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "日時", "editEntryDateDialogSetCustom": "日を設定する", "editEntryDateDialogCopyField": "他の日からコピーする", + "editEntryDateDialogCopyItem": "他のアイテムからコピーする", "editEntryDateDialogExtractFromTitle": "タイトルから抽出する", "editEntryDateDialogShift": "シフト", "editEntryDateDialogSourceFileModifiedDate": "ファイル更新日", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, other{{count} 件のアイテムを削除できませんでした}}", "collectionCopyFailureFeedback": "{count, plural, other{{count} 件のアイテムをコピーできませんでした}}", "collectionMoveFailureFeedback": "{count, plural, other{{count} 件のアイテムを移動できませんでした}}", + "collectionRenameFailureFeedback": "{count, plural, other{{count} 件のアイテム名を変更できませんでした}}", "collectionEditFailureFeedback": "{count, plural, other{{count} 件のアイテムを編集できませんでした}}", "collectionExportFailureFeedback": "{count, plural, other{{count} ページをエクスポートできませんでした}}", "collectionCopySuccessFeedback": "{count, plural, other{{count} 件のアイテムをコピーしました}}", "collectionMoveSuccessFeedback": "{count, plural, other{{count} 件のアイテムを移動しました}}", + "collectionRenameSuccessFeedback": "{count, plural, other{{count} 件のアイテム名を変更しました}}", "collectionEditSuccessFeedback": "{count, plural, other{{count} 件のアイテムを編集しました}}", "collectionEmptyFavourites": "お気に入りはありません", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "確認メッセージ", "settingsConfirmationDialogDeleteItems": "アイテムを完全に削除する前に確認", "settingsConfirmationDialogMoveToBinItems": "アイテムをごみ箱に移動する前に確認", + "settingsConfirmationDialogMoveUndatedItems": "メタデータ上に日付のないアイテムを移動する前に確認", "settingsNavigationDrawerTile": "ナビゲーション メニュー", "settingsNavigationDrawerEditorTitle": "ナビゲーション メニュー", @@ -448,8 +469,6 @@ "settingsVideoLoopModeTile": "ループ モード", "settingsVideoLoopModeTitle": "ループ モード", - "settingsVideoQuickActionsTile": "動画のクイック アクション", - "settingsVideoQuickActionEditorTitle": "クイック アクション", "settingsSubtitleThemeTile": "字幕", "settingsSubtitleThemeTitle": "字幕", "settingsSubtitleThemeSample": "これはサンプルです。", @@ -503,6 +522,12 @@ "settingsTimeToTakeActionTile": "操作までの時間", "settingsTimeToTakeActionTitle": "操作までの時間", + "settingsSectionDisplay": "ディスプレイ", + "settingsThemeBrightness": "テーマ", + "settingsThemeColorHighlights": "カラー強調表示", + "settingsDisplayRefreshRateModeTile": "ディスプレイ リフレッシュ レート", + "settingsDisplayRefreshRateModeTitle": "リフレッシュレート", + "settingsSectionLanguage": "言語と形式", "settingsLanguage": "言語", "settingsCoordinateFormatTile": "座標形式", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b5a4fd993..f46364bc0 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -137,6 +137,13 @@ "accessibilityAnimationsRemove": "화면 효과 제한", "accessibilityAnimationsKeep": "화면 효과 유지", + "displayRefreshRatePreferHighest": "가장 높은 재생률", + "displayRefreshRatePreferLowest": "가장 낮은 재생률", + + "themeBrightnessLight": "라이트", + "themeBrightnessDark": "다크", + "themeBrightnessBlack": "검은색", + "albumTierNew": "신규", "albumTierPinned": "고정", "albumTierSpecial": "기본", @@ -170,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 휴지통으로 이동하시겠습니까?} other{항목 {count}개를 휴지통으로 이동하시겠습니까?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}", + "moveUndatedConfirmationDialogMessage": "이 작업을 계속하기 전에 항목의 날짜를 지정하시겠습니까?", + "moveUndatedConfirmationDialogSetDate": "날짜 지정하기", "videoResumeDialogMessage": "{time}부터 재개하시겠습니까?", "videoStartOverButtonLabel": "처음부터", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "앨범 이름", "renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다", + "renameEntrySetPageTitle": "이름 변경", + "renameEntrySetPagePatternFieldLabel": "이름 양식", + "renameEntrySetPageInsertTooltip": "필드 추가", + "renameEntrySetPagePreview": "미리보기", + + "renameProcessorCounter": "숫자 증가", + "renameProcessorName": "이름", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogSetCustom": "지정 날짜로 편집", "editEntryDateDialogCopyField": "다른 날짜에서 지정", + "editEntryDateDialogCopyItem": "다른 항목에서 지정", "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogShift": "시간 이동", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}", "collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}", "collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}", + "collectionRenameFailureFeedback": "{count, plural, other{항목 {count}개의 이름을 변경하지 못했습니다}}", "collectionEditFailureFeedback": "{count, plural, other{항목 {count}개를 편집하지 못했습니다}}", "collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}", "collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}", "collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}", + "collectionRenameSuccessFeedback": "{count, plural, other{항목 {count}개의 이름을 변경했습니다}}", "collectionEditSuccessFeedback": "{count, plural, other{항목 {count}개를 편집했습니다}}", "collectionEmptyFavourites": "즐겨찾기가 없습니다", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "확정 대화상자", "settingsConfirmationDialogDeleteItems": "항목을 완전히 삭제 시", "settingsConfirmationDialogMoveToBinItems": "항목을 휴지통으로 이동 시", + "settingsConfirmationDialogMoveUndatedItems": "날짜가 지정되지 않은 항목을 이동 시", "settingsNavigationDrawerTile": "탐색 메뉴", "settingsNavigationDrawerEditorTitle": "탐색 메뉴", @@ -501,6 +522,12 @@ "settingsTimeToTakeActionTile": "액션 취하기 전 대기 시간", "settingsTimeToTakeActionTitle": "액션 취하기 전 대기 시간", + "settingsSectionDisplay": "디스플레이", + "settingsThemeBrightness": "테마", + "settingsThemeColorHighlights": "색 강조", + "settingsDisplayRefreshRateModeTile": "화면 재생률", + "settingsDisplayRefreshRateModeTitle": "화면 재생률", + "settingsSectionLanguage": "언어 및 표시 형식", "settingsLanguage": "언어", "settingsCoordinateFormatTile": "좌표 표현", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 55b03d590..8eedc96c6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -137,6 +137,13 @@ "accessibilityAnimationsRemove": "Prevenir efeitos de tela", "accessibilityAnimationsKeep": "Manter efeitos de tela", + "displayRefreshRatePreferHighest": "Taxa mais alta", + "displayRefreshRatePreferLowest": "Taxa mais baixa", + + "themeBrightnessLight": "Claro", + "themeBrightnessDark": "Escuro", + "themeBrightnessBlack": "Preto", + "albumTierNew": "Novo", "albumTierPinned": "Fixada", "albumTierSpecial": "Comum", @@ -170,6 +177,8 @@ "binEntriesConfirmationDialogMessage": "{count, plural, =1{Mover esse item para a lixeira?} other{Mova estes {count} itens para a lixeira?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este item?} other{Tem certeza de que deseja excluir estes {count} itens?}}", + "moveUndatedConfirmationDialogMessage": "Alguns itens não têm data de metadados. Sua data atual será redefinida por esta operação, a menos que um data de metadados é definida.", + "moveUndatedConfirmationDialogSetDate": "Definir data", "videoResumeDialogMessage": "Deseja continuar jogando em {time}?", "videoStartOverButtonLabel": "RECOMEÇAR", @@ -189,6 +198,14 @@ "renameAlbumDialogLabel": "Novo nome", "renameAlbumDialogLabelAlreadyExistsHelper": "O diretório já existe", + "renameEntrySetPageTitle": "Renomear", + "renameEntrySetPagePatternFieldLabel": "Padrão de nomeação", + "renameEntrySetPageInsertTooltip": "Inserir campo", + "renameEntrySetPagePreview": "Visualizar", + + "renameProcessorCounter": "Contador", + "renameProcessorName": "Nome", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este álbum e seu item?} other{Tem certeza de que deseja excluir este álbum e seus {count} itens?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir estes álbuns e seus itens?} other{Tem certeza de que deseja excluir estes álbuns e seus {count} itens?}}", @@ -201,6 +218,7 @@ "editEntryDateDialogTitle": "Data e hora", "editEntryDateDialogSetCustom": "Definir data personalizada", "editEntryDateDialogCopyField": "Copiar de outra data", + "editEntryDateDialogCopyItem": "Copiar de outro item", "editEntryDateDialogExtractFromTitle": "Extrair do título", "editEntryDateDialogShift": "Mudança", "editEntryDateDialogSourceFileModifiedDate": "Data de modificação do arquivo", @@ -308,10 +326,12 @@ "collectionDeleteFailureFeedback": "{count, plural, =1{Falha ao excluir 1 item} other{Falha ao excluir {count} itens}}", "collectionCopyFailureFeedback": "{count, plural, =1{Falha ao copiar 1 item} other{Falha ao copiar {count} itens}}", "collectionMoveFailureFeedback": "{count, plural, =1{Falha ao mover 1 item} other{Falha ao mover {count} itens}}", + "collectionRenameFailureFeedback": "{count, plural, =1{Falhei em renomear 1 item} other{Falha ao renomear {count} itens}}", "collectionEditFailureFeedback": "{count, plural, =1{Falha ao editar 1 item} other{Falha ao editar {count} itens}}", "collectionExportFailureFeedback": "{count, plural, =1{Falha ao exportar 1 página} other{Falha ao exportar {count} páginas}}", "collectionCopySuccessFeedback": "{count, plural, =1{1 item copiado} other{Copiado {count} itens}}", "collectionMoveSuccessFeedback": "{count, plural, =1{1 item movido} other{Mudou-se {count} itens}}", + "collectionRenameSuccessFeedback": "{count, plural, =1{1 item renomeado} other{Renomeado {count} itens}}", "collectionEditSuccessFeedback": "{count, plural, =1{Editado 1 item} other{Editado {count} itens}}", "collectionEmptyFavourites": "Nenhum favorito", @@ -393,6 +413,7 @@ "settingsConfirmationDialogTitle": "Caixas de diálogo de confirmação", "settingsConfirmationDialogDeleteItems": "Pergunte antes de excluir itens para sempre", "settingsConfirmationDialogMoveToBinItems": "Pergunte antes de mover itens para a lixeira", + "settingsConfirmationDialogMoveUndatedItems": "Pergunte antes de mover itens sem data de metadados", "settingsNavigationDrawerTile": "Menu de navegação", "settingsNavigationDrawerEditorTitle": "Menu de navegação", @@ -501,6 +522,12 @@ "settingsTimeToTakeActionTile": "Tempo para executar uma ação", "settingsTimeToTakeActionTitle": "Tempo para executar uma ação", + "settingsSectionDisplay": "Tela", + "settingsThemeBrightness": "Tema", + "settingsThemeColorHighlights": "Destaques de cores", + "settingsDisplayRefreshRateModeTile": "Taxa de atualização de exibição", + "settingsDisplayRefreshRateModeTitle": "Taxa de atualização", + "settingsSectionLanguage": "Idioma e Formatos", "settingsLanguage": "Língua", "settingsCoordinateFormatTile": "Formato de coordenadas", diff --git a/lib/main_common.dart b/lib/main_common.dart index 1a1820c9d..775607d8b 100644 --- a/lib/main_common.dart +++ b/lib/main_common.dart @@ -30,8 +30,9 @@ void mainCommon(AppFlavor flavor) { // Errors during the widget build phase will show by default: // - in debug mode: error on red background - // - in release mode: plain grey background + // - in profile/release mode: plain grey background // This can be modified via `ErrorWidget.builder` + // ErrorWidget.builder = (details) => ErrorWidget(details.exception); runApp(AvesApp(flavor: flavor)); } diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 65ce55e5a..f78daabeb 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -124,7 +124,7 @@ extension ExtraChipSetAction on ChipSetAction { return AIcons.unpin; // selecting (single filter) case ChipSetAction.rename: - return AIcons.rename; + return AIcons.name; case ChipSetAction.setCover: return AIcons.setCover; } diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index b9282b429..d43f54cf3 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -177,7 +177,8 @@ extension ExtraEntryAction on EntryAction { switch (this) { case EntryAction.debug: return ShaderMask( - shaderCallback: AColors.debugGradient.createShader, + shaderCallback: AvesColorsData.debugGradient.createShader, + blendMode: BlendMode.srcIn, child: child, ); default: @@ -200,7 +201,7 @@ extension ExtraEntryAction on EntryAction { case EntryAction.print: return AIcons.print; case EntryAction.rename: - return AIcons.rename; + return AIcons.name; case EntryAction.copy: return AIcons.copy; case EntryAction.move: diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 1498c8066..313bdb2e1 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -55,7 +55,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { switch (this) { case EntryInfoAction.debug: return ShaderMask( - shaderCallback: AColors.debugGradient.createShader, + shaderCallback: AvesColorsData.debugGradient.createShader, + blendMode: BlendMode.srcIn, child: child, ); default: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 7446ff8e4..1a9ad51b5 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -23,6 +23,7 @@ enum EntrySetAction { restore, copy, move, + rename, toggleFavourite, rotateCCW, rotateCW, @@ -68,6 +69,7 @@ class EntrySetActions { EntrySetAction.restore, EntrySetAction.copy, EntrySetAction.move, + EntrySetAction.rename, EntrySetAction.toggleFavourite, EntrySetAction.map, EntrySetAction.stats, @@ -81,6 +83,7 @@ class EntrySetActions { EntrySetAction.delete, EntrySetAction.copy, EntrySetAction.move, + EntrySetAction.rename, EntrySetAction.toggleFavourite, EntrySetAction.map, EntrySetAction.stats, @@ -137,6 +140,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionCopy; case EntrySetAction.move: return context.l10n.collectionActionMove; + case EntrySetAction.rename: + return context.l10n.entryActionRename; case EntrySetAction.toggleFavourite: // different data depending on toggle state return context.l10n.entryActionAddFavourite; @@ -200,6 +205,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.copy; case EntrySetAction.move: return AIcons.move; + case EntrySetAction.rename: + return AIcons.name; case EntrySetAction.toggleFavourite: // different data depending on toggle state return AIcons.favourite; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 2e5dedd16..0631fe22b 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -167,6 +167,7 @@ class AvesEntry { _directory = null; _filename = null; _extension = null; + _bestTitle = null; } String? get path => _path; @@ -258,9 +259,10 @@ class AvesEntry { bool get canRotateAndFlip => canEdit && canEditExif; // as of androidx.exifinterface:exifinterface:1.3.3 + // `exifinterface` declares support for DNG, but `exifinterface` strips non-standard Exif tags when saving attributes, + // and DNG requires DNG-specific tags saved along standard Exif. So `exifinterface` actually breaks DNG files. bool get canEditExif { switch (mimeType.toLowerCase()) { - case MimeTypes.dng: case MimeTypes.jpeg: case MimeTypes.png: case MimeTypes.webp: @@ -454,7 +456,7 @@ class AvesEntry { String? _bestTitle; String? get bestTitle { - _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle; + _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : (filenameWithoutExtension ?? sourceTitle); return _bestTitle; } diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 2b2ee6054..8a9856aa7 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -42,6 +42,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { switch (appliedModifier.action) { case DateEditAction.setCustom: case DateEditAction.copyField: + case DateEditAction.copyItem: case DateEditAction.extractFromTitle: editCreateDateXmp(descriptions, appliedModifier.setDateTime); break; @@ -319,6 +320,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final date = parseUnknownDateFormat(bestTitle); return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; case DateEditAction.setCustom: + case DateEditAction.copyItem: return DateModifier.setCustom(mainMetadataDate(), modifier.setDateTime!); case DateEditAction.shift: case DateEditAction.remove: diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 48fb78ab5..ffe4cd293 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -1,4 +1,3 @@ -import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; @@ -7,13 +6,11 @@ import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:provider/provider.dart'; class AlbumFilter extends CollectionFilter { static const type = 'album'; - static final Map _appColors = {}; - final String album; final String? displayName; @@ -56,6 +53,7 @@ class AlbumFilter extends CollectionFilter { @override Future color(BuildContext context) { + final colors = context.watch(); // do not use async/await and rely on `SynchronousFuture` // to prevent rebuilding of the `FutureBuilder` listening on this future final albumType = androidFileUtils.getAlbumType(album); @@ -63,31 +61,19 @@ class AlbumFilter extends CollectionFilter { case AlbumType.regular: break; case AlbumType.app: - if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!); - - final packageName = androidFileUtils.getAlbumAppPackageName(album); - if (packageName != null) { - return PaletteGenerator.fromImageProvider( - AppIconImage(packageName: packageName, size: 24), - ).then((palette) async { - // `dominantColor` is most representative but can have low contrast with a dark background - // `vibrantColor` is usually representative and has good contrast with a dark background - final color = palette.vibrantColor?.color ?? (await super.color(context)); - _appColors[album] = color; - return color; - }); - } + final appColor = colors.appColor(album); + if (appColor != null) return appColor; break; case AlbumType.camera: - return SynchronousFuture(AColors.albumCamera); + return SynchronousFuture(colors.albumCamera); case AlbumType.download: - return SynchronousFuture(AColors.albumDownload); + return SynchronousFuture(colors.albumDownload); case AlbumType.screenRecordings: - return SynchronousFuture(AColors.albumScreenRecordings); + return SynchronousFuture(colors.albumScreenRecordings); case AlbumType.screenshots: - return SynchronousFuture(AColors.albumScreenshots); + return SynchronousFuture(colors.albumScreenshots); case AlbumType.videoCaptures: - return SynchronousFuture(AColors.albumVideoCaptures); + return SynchronousFuture(colors.albumVideoCaptures); } return super.color(context); } diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index afa6116a6..964992f8c 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -4,6 +4,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; @@ -33,7 +34,10 @@ class FavouriteFilter extends CollectionFilter { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size); @override - Future color(BuildContext context) => SynchronousFuture(AColors.favourite); + Future color(BuildContext context) { + final colors = context.watch(); + return SynchronousFuture(colors.favourite); + } @override String get category => type; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 2c9da7615..567d45a62 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -12,11 +12,12 @@ import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/type.dart'; -import 'package:aves/utils/color_utils.dart'; +import 'package:aves/theme/colors.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; @immutable abstract class CollectionFilter extends Equatable implements Comparable { @@ -93,7 +94,10 @@ abstract class CollectionFilter extends Equatable implements Comparable null; - Future color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context))); + Future color(BuildContext context) { + final colors = context.watch(); + return SynchronousFuture(colors.fromString(getLabel(context))); + } String get category; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9bdc1c753..9e81157cc 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -1,12 +1,14 @@ +import 'dart:async'; + import 'package:aves/model/filters/filters.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; class MimeFilter extends CollectionFilter { static const type = 'mime'; @@ -15,7 +17,6 @@ class MimeFilter extends CollectionFilter { late final EntryFilter _test; late final String _label; late final IconData _icon; - late final Color _color; static final image = MimeFilter(MimeTypes.anyImage); static final video = MimeFilter(MimeTypes.anyVideo); @@ -25,7 +26,6 @@ class MimeFilter extends CollectionFilter { MimeFilter(this.mime) { IconData? icon; - Color? color; var lowMime = mime.toLowerCase(); if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); @@ -33,17 +33,14 @@ class MimeFilter extends CollectionFilter { _label = lowMime.toUpperCase(); if (mime == MimeTypes.anyImage) { icon = AIcons.image; - color = AColors.image; } else if (mime == MimeTypes.anyVideo) { icon = AIcons.video; - color = AColors.video; } } else { _test = (entry) => entry.mimeType == lowMime; _label = MimeUtils.displayType(lowMime); } _icon = icon ?? AIcons.vector; - _color = color ?? stringToColor(_label); } MimeFilter.fromMap(Map json) @@ -79,7 +76,17 @@ class MimeFilter extends CollectionFilter { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); @override - Future color(BuildContext context) => SynchronousFuture(_color); + Future color(BuildContext context) { + final colors = context.watch(); + switch (mime) { + case MimeTypes.anyImage: + return SynchronousFuture(colors.image); + case MimeTypes.anyVideo: + return SynchronousFuture(colors.video); + default: + return SynchronousFuture(colors.fromString(_label)); + } + } @override String get category => type; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index b7c5e210d..e8fe793ea 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -1,10 +1,11 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; class QueryFilter extends CollectionFilter { static const type = 'query'; @@ -67,7 +68,14 @@ class QueryFilter extends CollectionFilter { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size); @override - Future color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor); + Future color(BuildContext context) { + if (colorful) { + return super.color(context); + } + + final colors = context.watch(); + return SynchronousFuture(colors.neutral); + } @override String get category => type; diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index 1d2b986f0..e4565a671 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -4,6 +4,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; class TypeFilter extends CollectionFilter { static const type = 'type'; @@ -18,7 +19,6 @@ class TypeFilter extends CollectionFilter { final String itemType; late final EntryFilter _test; late final IconData _icon; - late final Color _color; static final animated = TypeFilter._private(_animated); static final geotiff = TypeFilter._private(_geotiff); @@ -35,32 +35,26 @@ class TypeFilter extends CollectionFilter { case _animated: _test = (entry) => entry.isAnimated; _icon = AIcons.animated; - _color = AColors.animated; break; case _geotiff: _test = (entry) => entry.isGeotiff; _icon = AIcons.geo; - _color = AColors.geotiff; break; case _motionPhoto: _test = (entry) => entry.isMotionPhoto; _icon = AIcons.motionPhoto; - _color = AColors.motionPhoto; break; case _panorama: _test = (entry) => entry.isImage && entry.is360; _icon = AIcons.threeSixty; - _color = AColors.panorama; break; case _raw: _test = (entry) => entry.isRaw; _icon = AIcons.raw; - _color = AColors.raw; break; case _sphericalVideo: _test = (entry) => entry.isVideo && entry.is360; _icon = AIcons.threeSixty; - _color = AColors.sphericalVideo; break; } } @@ -106,7 +100,24 @@ class TypeFilter extends CollectionFilter { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); @override - Future color(BuildContext context) => SynchronousFuture(_color); + Future color(BuildContext context) { + final colors = context.watch(); + switch (itemType) { + case _animated: + return SynchronousFuture(colors.animated); + case _geotiff: + return SynchronousFuture(colors.geotiff); + case _motionPhoto: + return SynchronousFuture(colors.motionPhoto); + case _panorama: + return SynchronousFuture(colors.panorama); + case _raw: + return SynchronousFuture(colors.raw); + case _sphericalVideo: + return SynchronousFuture(colors.sphericalVideo); + } + return super.color(context); + } @override String get category => type; diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 73d648463..3e0c20f94 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -24,30 +24,30 @@ class DateModifier extends Equatable { List get props => [action, fields, setDateTime, copyFieldSource, shiftMinutes]; const DateModifier._private( - this.action, - this.fields, { + this.action, { + this.fields = const {}, this.setDateTime, this.copyFieldSource, this.shiftMinutes, }); factory DateModifier.setCustom(Set fields, DateTime dateTime) { - return DateModifier._private(DateEditAction.setCustom, fields, setDateTime: dateTime); + return DateModifier._private(DateEditAction.setCustom, fields: fields, setDateTime: dateTime); } - factory DateModifier.copyField(Set fields, DateFieldSource copyFieldSource) { - return DateModifier._private(DateEditAction.copyField, fields, copyFieldSource: copyFieldSource); + factory DateModifier.copyField(DateFieldSource copyFieldSource) { + return DateModifier._private(DateEditAction.copyField, copyFieldSource: copyFieldSource); } - factory DateModifier.extractFromTitle(Set fields) { - return DateModifier._private(DateEditAction.extractFromTitle, fields); + factory DateModifier.extractFromTitle() { + return const DateModifier._private(DateEditAction.extractFromTitle); } factory DateModifier.shift(Set fields, int shiftMinutes) { - return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes); + return DateModifier._private(DateEditAction.shift, fields: fields, shiftMinutes: shiftMinutes); } factory DateModifier.remove(Set fields) { - return DateModifier._private(DateEditAction.remove, fields); + return DateModifier._private(DateEditAction.remove, fields: fields); } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 086f35894..e35f8c743 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -3,6 +3,7 @@ import 'package:aves/model/metadata/fields.dart'; enum DateEditAction { setCustom, copyField, + copyItem, extractFromTitle, shift, remove, diff --git a/lib/model/metadata/overlay.dart b/lib/model/metadata/overlay.dart index 9dc983af3..1858642c1 100644 --- a/lib/model/metadata/overlay.dart +++ b/lib/model/metadata/overlay.dart @@ -12,6 +12,8 @@ class OverlayMetadata extends Equatable { bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; + bool get isNotEmpty => !isEmpty; + const OverlayMetadata({ this.aperture, this.exposureTime, diff --git a/lib/model/naming_pattern.dart b/lib/model/naming_pattern.dart new file mode 100644 index 000000000..2cbe98dfc --- /dev/null +++ b/lib/model/naming_pattern.dart @@ -0,0 +1,184 @@ +import 'package:aves/model/entry.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +@immutable +class NamingPattern { + final List processors; + + static final processorPattern = RegExp(r'<(.+?)(,(.+?))?>'); + static const processorOptionSeparator = ','; + static const optionKeyValueSeparator = '='; + + const NamingPattern(this.processors); + + factory NamingPattern.from({ + required String userPattern, + required int entryCount, + }) { + final processors = []; + + const defaultCounterStart = 1; + final defaultCounterPadding = '$entryCount'.length; + + var index = 0; + final matches = processorPattern.allMatches(userPattern); + matches.forEach((match) { + final start = match.start; + final end = match.end; + if (index < start) { + processors.add(LiteralNamingProcessor(userPattern.substring(index, start))); + index = start; + } + final processorKey = match.group(1); + final processorOptions = match.group(3); + switch (processorKey) { + case DateNamingProcessor.key: + if (processorOptions != null) { + processors.add(DateNamingProcessor(processorOptions.trim())); + } + break; + case NameNamingProcessor.key: + processors.add(const NameNamingProcessor()); + break; + case CounterNamingProcessor.key: + int? start, padding; + _applyProcessorOptions(processorOptions, (key, value) { + final valueInt = int.tryParse(value); + if (valueInt != null) { + switch (key) { + case CounterNamingProcessor.optionStart: + start = valueInt; + break; + case CounterNamingProcessor.optionPadding: + padding = valueInt; + break; + } + } + }); + processors.add(CounterNamingProcessor(start: start ?? defaultCounterStart, padding: padding ?? defaultCounterPadding)); + break; + default: + debugPrint('unsupported naming processor: ${match.group(0)}'); + break; + } + index = end; + }); + if (index < userPattern.length) { + processors.add(LiteralNamingProcessor(userPattern.substring(index, userPattern.length))); + } + + return NamingPattern(processors); + } + + static void _applyProcessorOptions(String? processorOptions, void Function(String key, String value) applyOption) { + if (processorOptions != null) { + processorOptions.split(processorOptionSeparator).map((v) => v.trim()).forEach((kv) { + final parts = kv.split(optionKeyValueSeparator); + if (parts.length >= 2) { + final key = parts[0]; + final value = parts.skip(1).join(optionKeyValueSeparator); + applyOption(key, value); + } + }); + } + } + + static int getInsertionOffset(String userPattern, int offset) { + offset = offset.clamp(0, userPattern.length); + final matches = processorPattern.allMatches(userPattern); + for (final match in matches) { + final start = match.start; + final end = match.end; + if (offset <= start) return offset; + if (offset <= end) return end; + } + return offset; + } + + static String defaultPatternFor(String processorKey) { + switch (processorKey) { + case DateNamingProcessor.key: + return '<$processorKey, yyyyMMdd-HHmmss>'; + case CounterNamingProcessor.key: + case NameNamingProcessor.key: + default: + return '<$processorKey>'; + } + } + + String apply(AvesEntry entry, int index) => processors.map((v) => v.process(entry, index) ?? '').join().trimLeft(); +} + +@immutable +abstract class NamingProcessor extends Equatable { + const NamingProcessor(); + + String? process(AvesEntry entry, int index); +} + +@immutable +class LiteralNamingProcessor extends NamingProcessor { + final String text; + + @override + List get props => [text]; + + const LiteralNamingProcessor(this.text); + + @override + String? process(AvesEntry entry, int index) => text; +} + +@immutable +class DateNamingProcessor extends NamingProcessor { + static const key = 'date'; + + final DateFormat format; + + @override + List get props => [format.pattern]; + + DateNamingProcessor(String pattern) : format = DateFormat(pattern); + + @override + String? process(AvesEntry entry, int index) { + final date = entry.bestDate; + return date != null ? format.format(date) : null; + } +} + +@immutable +class NameNamingProcessor extends NamingProcessor { + static const key = 'name'; + + @override + List get props => []; + + const NameNamingProcessor(); + + @override + String? process(AvesEntry entry, int index) => entry.filenameWithoutExtension; +} + +@immutable +class CounterNamingProcessor extends NamingProcessor { + final int start; + final int padding; + + static const key = 'counter'; + static const optionStart = 'start'; + static const optionPadding = 'padding'; + + @override + List get props => [start, padding]; + + const CounterNamingProcessor({ + required this.start, + required this.padding, + }); + + @override + String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0'); +} diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index afa1f219f..21d5baddd 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -2,6 +2,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/naming_pattern.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; @@ -15,13 +16,20 @@ class SettingsDefaults { static const canUseAnalysisService = true; static const isInstalledAppAccessAllowed = false; static const isErrorReportingAllowed = false; + static const displayRefreshRateMode = DisplayRefreshRateMode.auto; + static const themeBrightness = AvesThemeBrightness.system; + static const themeColorMode = AvesThemeColorMode.polychrome; static const tileLayout = TileLayout.grid; + static const entryRenamingPattern = '<${DateNamingProcessor.key}, yyyyMMdd-HHmmss> <${NameNamingProcessor.key}>'; // navigation static const mustBackTwiceToExit = true; static const keepScreenOn = KeepScreenOn.viewerOnly; static const homePage = HomePageSetting.collection; - static const confirmationDialogs = ConfirmationDialog.values; + static const confirmDeleteForever = true; + static const confirmMoveToBin = true; + static const confirmMoveUndatedItems = true; + static const setMetadataDateBeforeFileOp = false; static final drawerTypeBookmarks = [ null, MimeFilter.video, @@ -58,9 +66,10 @@ class SettingsDefaults { // viewer static const viewerQuickActions = [ + EntryAction.rotateScreen, EntryAction.toggleFavourite, EntryAction.share, - EntryAction.rotateScreen, + EntryAction.delete, ]; static const showOverlayOnOpening = true; static const showOverlayMinimap = false; diff --git a/lib/model/settings/enums/confirmation_dialogs.dart b/lib/model/settings/enums/confirmation_dialogs.dart deleted file mode 100644 index 4f1a58742..000000000 --- a/lib/model/settings/enums/confirmation_dialogs.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/widgets.dart'; - -import 'enums.dart'; - -extension ExtraConfirmationDialog on ConfirmationDialog { - String getName(BuildContext context) { - switch (this) { - case ConfirmationDialog.delete: - return context.l10n.settingsConfirmationDialogDeleteItems; - case ConfirmationDialog.moveToBin: - return context.l10n.settingsConfirmationDialogMoveToBinItems; - } - } -} diff --git a/lib/model/settings/enums/display_refresh_rate_mode.dart b/lib/model/settings/enums/display_refresh_rate_mode.dart new file mode 100644 index 000000000..d650d5337 --- /dev/null +++ b/lib/model/settings/enums/display_refresh_rate_mode.dart @@ -0,0 +1,33 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; + +import 'enums.dart'; + +extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode { + String getName(BuildContext context) { + switch (this) { + case DisplayRefreshRateMode.auto: + return context.l10n.settingsSystemDefault; + case DisplayRefreshRateMode.highest: + return context.l10n.displayRefreshRatePreferHighest; + case DisplayRefreshRateMode.lowest: + return context.l10n.displayRefreshRatePreferLowest; + } + } + + void apply() { + debugPrint('Apply display refresh rate: $name'); + switch (this) { + case DisplayRefreshRateMode.auto: + FlutterDisplayMode.setPreferredMode(DisplayMode.auto); + break; + case DisplayRefreshRateMode.highest: + FlutterDisplayMode.setHighRefreshRate(); + break; + case DisplayRefreshRateMode.lowest: + FlutterDisplayMode.setLowRefreshRate(); + break; + } + } +} diff --git a/lib/model/settings/enums/enums.dart b/lib/model/settings/enums/enums.dart index f5100f413..461541d2c 100644 --- a/lib/model/settings/enums/enums.dart +++ b/lib/model/settings/enums/enums.dart @@ -2,7 +2,11 @@ enum AccessibilityAnimations { system, disabled, enabled } enum AccessibilityTimeout { system, appDefault, s10, s30, s60, s120 } -enum ConfirmationDialog { delete, moveToBin } +enum AvesThemeColorMode { monochrome, polychrome } + +enum AvesThemeBrightness { system, light, dark, black } + +enum ConfirmationDialog { deleteForever, moveToBin, moveUndatedItems } enum CoordinateFormat { dms, decimal } @@ -15,6 +19,8 @@ enum HomePageSetting { collection, albums } enum KeepScreenOn { never, viewerOnly, always } +enum DisplayRefreshRateMode { auto, highest, lowest } + enum UnitSystem { metric, imperial } enum VideoLoopMode { never, shortOnly, always } diff --git a/lib/model/settings/enums/theme_brightness.dart b/lib/model/settings/enums/theme_brightness.dart new file mode 100644 index 000000000..5edf515ae --- /dev/null +++ b/lib/model/settings/enums/theme_brightness.dart @@ -0,0 +1,31 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; + +import 'enums.dart'; + +extension ExtraAvesThemeBrightness on AvesThemeBrightness { + String getName(BuildContext context) { + switch (this) { + case AvesThemeBrightness.system: + return context.l10n.settingsSystemDefault; + case AvesThemeBrightness.light: + return context.l10n.themeBrightnessLight; + case AvesThemeBrightness.dark: + return context.l10n.themeBrightnessDark; + case AvesThemeBrightness.black: + return context.l10n.themeBrightnessBlack; + } + } + + ThemeMode get appThemeMode { + switch (this) { + case AvesThemeBrightness.system: + return ThemeMode.system; + case AvesThemeBrightness.light: + return ThemeMode.light; + case AvesThemeBrightness.dark: + case AvesThemeBrightness.black: + return ThemeMode.dark; + } + } +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 67b8f7e0f..e35066bfd 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -41,16 +41,23 @@ class Settings extends ChangeNotifier { static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed'; static const isErrorReportingAllowedKey = 'is_crashlytics_enabled'; static const localeKey = 'locale'; + static const displayRefreshRateModeKey = 'display_refresh_rate_mode'; + static const themeBrightnessKey = 'theme_brightness'; + static const themeColorModeKey = 'theme_color_mode'; static const catalogTimeZoneKey = 'catalog_time_zone'; static const tileExtentPrefixKey = 'tile_extent_'; static const tileLayoutPrefixKey = 'tile_layout_'; + static const entryRenamingPatternKey = 'entry_renaming_pattern'; static const topEntryIdsKey = 'top_entry_ids'; // navigation static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const keepScreenOnKey = 'keep_screen_on'; static const homePageKey = 'home_page'; - static const confirmationDialogsKey = 'confirmation_dialogs'; + static const confirmDeleteForeverKey = 'confirm_delete_forever'; + static const confirmMoveToBinKey = 'confirm_move_to_bin'; + static const confirmMoveUndatedItemsKey = 'confirm_move_undated_items'; + static const setMetadataDateBeforeFileOpKey = 'set_metadata_date_before_file_op'; static const drawerTypeBookmarksKey = 'drawer_type_bookmarks'; static const drawerAlbumBookmarksKey = 'drawer_album_bookmarks'; static const drawerPageBookmarksKey = 'drawer_page_bookmarks'; @@ -239,6 +246,18 @@ class Settings extends ChangeNotifier { return _appliedLocale!; } + DisplayRefreshRateMode get displayRefreshRateMode => getEnumOrDefault(displayRefreshRateModeKey, SettingsDefaults.displayRefreshRateMode, DisplayRefreshRateMode.values); + + set displayRefreshRateMode(DisplayRefreshRateMode newValue) => setAndNotify(displayRefreshRateModeKey, newValue.toString()); + + AvesThemeBrightness get themeBrightness => getEnumOrDefault(themeBrightnessKey, SettingsDefaults.themeBrightness, AvesThemeBrightness.values); + + set themeBrightness(AvesThemeBrightness newValue) => setAndNotify(themeBrightnessKey, newValue.toString()); + + AvesThemeColorMode get themeColorMode => getEnumOrDefault(themeColorModeKey, SettingsDefaults.themeColorMode, AvesThemeColorMode.values); + + set themeColorMode(AvesThemeColorMode newValue) => setAndNotify(themeColorModeKey, newValue.toString()); + String get catalogTimeZone => getString(catalogTimeZoneKey) ?? ''; set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue); @@ -251,6 +270,10 @@ class Settings extends ChangeNotifier { void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString()); + String get entryRenamingPattern => getString(entryRenamingPatternKey) ?? SettingsDefaults.entryRenamingPattern; + + set entryRenamingPattern(String newValue) => setAndNotify(entryRenamingPatternKey, newValue); + List? get topEntryIds => getStringList(topEntryIdsKey)?.map(int.tryParse).whereNotNull().toList(); set topEntryIds(List? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList()); @@ -269,9 +292,21 @@ class Settings extends ChangeNotifier { set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString()); - List get confirmationDialogs => getEnumListOrDefault(confirmationDialogsKey, SettingsDefaults.confirmationDialogs, ConfirmationDialog.values); + bool get confirmDeleteForever => getBoolOrDefault(confirmDeleteForeverKey, SettingsDefaults.confirmDeleteForever); + + set confirmDeleteForever(bool newValue) => setAndNotify(confirmDeleteForeverKey, newValue); + + bool get confirmMoveToBin => getBoolOrDefault(confirmMoveToBinKey, SettingsDefaults.confirmMoveToBin); + + set confirmMoveToBin(bool newValue) => setAndNotify(confirmMoveToBinKey, newValue); + + bool get confirmMoveUndatedItems => getBoolOrDefault(confirmMoveUndatedItemsKey, SettingsDefaults.confirmMoveUndatedItems); + + set confirmMoveUndatedItems(bool newValue) => setAndNotify(confirmMoveUndatedItemsKey, newValue); + + bool get setMetadataDateBeforeFileOp => getBoolOrDefault(setMetadataDateBeforeFileOpKey, SettingsDefaults.setMetadataDateBeforeFileOp); - set confirmationDialogs(List newValue) => setAndNotify(confirmationDialogsKey, newValue.map((v) => v.toString()).toList()); + set setMetadataDateBeforeFileOp(bool newValue) => setAndNotify(setMetadataDateBeforeFileOpKey, newValue); List get drawerTypeBookmarks => (getStringList(drawerTypeBookmarksKey))?.map((v) { @@ -646,6 +681,10 @@ class Settings extends ChangeNotifier { case isInstalledAppAccessAllowedKey: case isErrorReportingAllowedKey: case mustBackTwiceToExitKey: + case confirmDeleteForeverKey: + case confirmMoveToBinKey: + case confirmMoveUndatedItemsKey: + case setMetadataDateBeforeFileOpKey: case showThumbnailFavouriteKey: case showThumbnailLocationKey: case showThumbnailMotionPhotoKey: @@ -675,6 +714,9 @@ class Settings extends ChangeNotifier { } break; case localeKey: + case displayRefreshRateModeKey: + case themeBrightnessKey: + case themeColorModeKey: case keepScreenOnKey: case homePageKey: case collectionGroupFactorKey: @@ -698,7 +740,6 @@ class Settings extends ChangeNotifier { debugPrint('failed to import key=$key, value=$newValue is not a string'); } break; - case confirmationDialogsKey: case drawerTypeBookmarksKey: case drawerAlbumBookmarksKey: case drawerPageBookmarksKey: diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 83cc962d8..cda295720 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -325,4 +325,7 @@ class CollectionLens with ChangeNotifier { sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty))); notifyListeners(); } + + @override + String toString() => '$runtimeType#${shortHash(this)}{id=$id, source=$source, filters=$filters, entryCount=$entryCount}'; } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index cec81045a..e1dc3c4fa 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -230,38 +230,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } - Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { - if (newName == entry.filenameWithoutExtension) return true; - - pauseMonitoring(); - final completer = Completer(); - final processed = {}; - mediaFileService.rename({entry}, newName: '$newName${entry.extension}').listen( - processed.add, - onError: (error) => reportService.recordError('renameEntry failed with error=$error', null), - onDone: () async { - final successOps = processed.where((e) => e.success && !e.skipped).toSet(); - if (successOps.isEmpty) { - completer.complete(false); - return; - } - final newFields = successOps.first.newFields; - if (newFields.isEmpty) { - completer.complete(false); - return; - } - await _moveEntry(entry, newFields, persist: persist); - entry.metadataChangeNotifier.notify(); - eventBus.fire(EntryMovedEvent(MoveType.move, {entry})); - completer.complete(true); - }, - ); - - final success = await completer.future; - resumeMonitoring(); - return success; - } - Future renameAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { final oldFilter = AlbumFilter(sourceAlbum, null); final newFilter = AlbumFilter(destinationAlbum, null); @@ -338,7 +306,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM }); } - switch(moveType) { + switch (moveType) { case MoveType.copy: addEntries(movedEntries); break; @@ -357,6 +325,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } + Future updateAfterRename({ + required Set todoEntries, + required Set movedOps, + required bool persist, + }) async { + if (movedOps.isEmpty) return; + + final movedEntries = {}; + await Future.forEach(movedOps, (movedOp) async { + final newFields = movedOp.newFields; + if (newFields.isNotEmpty) { + final sourceUri = movedOp.uri; + final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); + if (entry != null) { + movedEntries.add(entry); + await _moveEntry(entry, newFields, persist: persist); + } + } + }); + + eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries)); + } + SourceInitializationState get initState => SourceInitializationState.none; Future init({ diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index deca24b85..8eb3f8fc7 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -75,7 +75,7 @@ mixin LocationMixin on SourceBase { // full reverse geocoding, requiring Play Services and some connectivity Future _locatePlaces(AnalysisController controller, Set candidateEntries) async { if (controller.isStopping) return; - if (!(await availability.canLocatePlaces)) return; + if (!await availability.canLocatePlaces) return; final force = controller.force; final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet(); diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 46c3210ca..e8fe36277 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -92,9 +92,9 @@ abstract class MediaFileService { required NameConflictStrategy nameConflictStrategy, }); - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map entriesToNewName, }); Future> captureFrame( @@ -392,16 +392,16 @@ class PlatformMediaFileService implements MediaFileService { } @override - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map entriesToNewName, }) { try { return _opStreamChannel .receiveBroadcastStream({ 'op': 'rename', - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'newName': newName, + 'id': opId, + 'entriesToNewName': entriesToNewName.map((key, value) => MapEntry(_toPlatformEntryMap(key), value)), }) .where((event) => event is Map) .map((event) => MoveOpEvent.fromMap(event as Map)); diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index e430be7a0..bb17bd746 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -1,37 +1,125 @@ -import 'package:aves/utils/color_utils.dart'; +import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:provider/provider.dart'; + +class AvesColorsProvider extends StatelessWidget { + final Widget child; + + const AvesColorsProvider({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProxyProvider( + update: (context, settings, __) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (settings.themeColorMode) { + case AvesThemeColorMode.monochrome: + return isDark ? _MonochromeOnDark() : _MonochromeOnLight(); + case AvesThemeColorMode.polychrome: + return isDark ? NeonOnDark() : PastelOnLight(); + } + }, + child: child, + ); + } +} + +abstract class AvesColorsData { + Color get neutral; + + Color fromHue(double hue); + + Color? fromBrandColor(Color? color); + + final Map _stringColors = {}, _appColors = {}; + + Color fromString(String string) { + var color = _stringColors[string]; + if (color == null) { + final hash = string.codeUnits.fold(0, (prev, el) => prev = el + ((prev << 5) - prev)); + final hue = (hash % 360).toDouble(); + color = fromHue(hue); + _stringColors[string] = color; + } + return color; + } + + Future? appColor(String album) { + if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!); + + final packageName = androidFileUtils.getAlbumAppPackageName(album); + if (packageName == null) return null; + + return PaletteGenerator.fromImageProvider( + AppIconImage(packageName: packageName, size: 24), + ).then((palette) async { + // `dominantColor` is most representative but can have low contrast with a dark background + // `vibrantColor` is usually representative and has good contrast with a dark background + final color = palette.vibrantColor?.color ?? fromString(album); + _appColors[album] = color; + return color; + }); + } + + static const Color _neutralOnDark = Colors.white; + static const Color _neutralOnLight = Color(0xAA000000); -class AColors { // mime - static final image = stringToColor('Image'); - static final video = stringToColor('Video'); + Color get image => fromHue(243); + + Color get video => fromHue(323); // type - static const favourite = Colors.red; - static final animated = stringToColor('Animated'); - static final geotiff = stringToColor('GeoTIFF'); - static final motionPhoto = stringToColor('Motion Photo'); - static final panorama = stringToColor('Panorama'); - static final raw = stringToColor('Raw'); - static final sphericalVideo = stringToColor('360° Video'); + Color get favourite => fromHue(0); + + Color get animated => fromHue(83); + + Color get geotiff => fromHue(70); + + Color get motionPhoto => fromHue(104); + + Color get panorama => fromHue(5); + + Color get raw => fromHue(208); + + Color get sphericalVideo => fromHue(174); // albums - static final albumCamera = stringToColor('Camera'); - static final albumDownload = stringToColor('Download'); - static final albumScreenshots = stringToColor('Screenshots'); - static final albumScreenRecordings = stringToColor('Screen recordings'); - static final albumVideoCaptures = stringToColor('Video Captures'); + Color get albumCamera => fromHue(165); + + Color get albumDownload => fromHue(104); + + Color get albumScreenshots => fromHue(149); + + Color get albumScreenRecordings => fromHue(222); + + Color get albumVideoCaptures => fromHue(266); // info - static final xmp = stringToColor('XMP'); + Color get xmp => fromHue(275); // settings - static final accessibility = stringToColor('Accessibility'); - static final language = stringToColor('Language'); - static final navigation = stringToColor('Navigation'); - static final privacy = stringToColor('Privacy'); - static final thumbnails = stringToColor('Thumbnails'); + Color get accessibility => fromHue(134); + Color get display => fromHue(50); + + Color get language => fromHue(264); + + Color get navigation => fromHue(140); + + Color get privacy => fromHue(344); + + Color get thumbnails => fromHue(87); + + // debug static const debugGradient = LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, @@ -41,3 +129,51 @@ class AColors { ], ); } + +abstract class _Monochrome extends AvesColorsData { + @override + Color fromHue(double hue) => neutral; + + @override + Color? fromBrandColor(Color? color) => neutral; + + @override + Color fromString(String string) => neutral; + + @override + Future? appColor(String album) => SynchronousFuture(neutral); +} + +class _MonochromeOnDark extends _Monochrome { + @override + Color get neutral => AvesColorsData._neutralOnDark; +} + +class _MonochromeOnLight extends _Monochrome { + @override + Color get neutral => AvesColorsData._neutralOnLight; +} + +class NeonOnDark extends AvesColorsData { + @override + Color get neutral => AvesColorsData._neutralOnDark; + + @override + Color fromHue(double hue) => HSLColor.fromAHSL(1.0, hue, .8, .6).toColor(); + + @override + Color? fromBrandColor(Color? color) => color; +} + +class PastelOnLight extends AvesColorsData { + @override + Color get neutral => AvesColorsData._neutralOnLight; + + @override + Color fromHue(double hue) => _pastellize(HSLColor.fromAHSL(1.0, hue, .8, .6).toColor()); + + @override + Color? fromBrandColor(Color? color) => color != null ? _pastellize(color) : null; + + Color _pastellize(Color color) => Color.lerp(color, Colors.white, .5)!; +} diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index da4010ca9..c13392c3c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -47,6 +47,7 @@ class Durations { static const tagEditorTransition = Duration(milliseconds: 200); // settings animations + static const themeColorModeAnimation = Duration(milliseconds: 400); static const quickActionListAnimation = Duration(milliseconds: 200); static const quickActionHighlightAnimation = Duration(milliseconds: 200); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 8c02d5913..eeabd06ee 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -12,8 +12,10 @@ class AIcons { static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; + static const IconData counter = Icons.plus_one_outlined; static const IconData date = Icons.calendar_today_outlined; static const IconData disc = Icons.fiber_manual_record; + static const IconData display = Icons.light_mode_outlined; static const IconData error = Icons.error_outline; static const IconData folder = Icons.folder_outlined; static const IconData grid = Icons.grid_on_outlined; @@ -74,6 +76,7 @@ class AIcons { static const IconData move = MdiIcons.fileMoveOutline; static const IconData mute = Icons.volume_off_outlined; static const IconData unmute = Icons.volume_up_outlined; + static const IconData name = Icons.abc_outlined; static const IconData newTier = Icons.fiber_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined; static const IconData pin = Icons.push_pin_outlined; @@ -82,7 +85,6 @@ class AIcons { static const IconData pause = Icons.pause; static const IconData print = Icons.print_outlined; static const IconData refresh = Icons.refresh_outlined; - static const IconData rename = Icons.title_outlined; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; static const IconData reset = Icons.restart_alt_outlined; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 795c00371..034874289 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -1,50 +1,181 @@ import 'dart:ui'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; class Themes { static const _accentColor = Colors.indigoAccent; - static final darkTheme = ThemeData( - brightness: Brightness.dark, - // canvas color is used as background for the drawer and popups - // when using a popup menu on a dialog, lighten the background via `PopupMenuTheme` - canvasColor: Colors.grey[850], - scaffoldBackgroundColor: Colors.grey.shade900, - dialogBackgroundColor: Colors.grey[850], + static const _tooltipTheme = TooltipThemeData( + verticalOffset: 32, + ); + + static const _appBarTitleTextStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + fontFeatures: [FontFeature.enable('smcp')], + ); + + static const _snackBarTheme = SnackBarThemeData( + actionTextColor: _accentColor, + behavior: SnackBarBehavior.floating, + ); + + static final _typography = Typography.material2018(platform: TargetPlatform.android); + + static final _lightThemeTypo = _typography.black; + static final _lightTitleColor = _lightThemeTypo.titleMedium!.color!; + static final _lightBodyColor = _lightThemeTypo.bodyMedium!.color!; + static final _lightLabelColor = _lightThemeTypo.labelMedium!.color!; + static const _lightActionIconColor = Color(0xAA000000); + static const _lightFirstLayer = Color(0xFFFAFAFA); // aka `Colors.grey[50]` + static const _lightSecondLayer = Color(0xFFF5F5F5); // aka `Colors.grey[100]` + static const _lightThirdLayer = Color(0xFFEEEEEE); // aka `Colors.grey[200]` + + static final lightTheme = ThemeData( + colorScheme: ColorScheme.light( + primary: _accentColor, + secondary: _accentColor, + onPrimary: _lightBodyColor, + onSecondary: _lightBodyColor, + ), + brightness: Brightness.light, + // `canvasColor` is used by `Drawer`, `DropdownButton` and `ExpansionTileCard` + canvasColor: _lightSecondLayer, + scaffoldBackgroundColor: _lightFirstLayer, + // `cardColor` is used by `ExpansionPanel` + cardColor: _lightSecondLayer, + dialogBackgroundColor: _lightSecondLayer, indicatorColor: _accentColor, toggleableActiveColor: _accentColor, - tooltipTheme: const TooltipThemeData( - verticalOffset: 32, - ), + typography: _typography, appBarTheme: AppBarTheme( - backgroundColor: Colors.grey.shade900, - titleTextStyle: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.normal, - fontFeatures: [FontFeature.enable('smcp')], + backgroundColor: _lightFirstLayer, + // `foregroundColor` is used by icons + foregroundColor: _lightActionIconColor, + // `titleTextStyle.color` is used by text + titleTextStyle: _appBarTitleTextStyle.copyWith(color: _lightTitleColor), + systemOverlayStyle: SystemUiOverlayStyle.dark, + ), + listTileTheme: const ListTileThemeData( + iconColor: _lightActionIconColor, + ), + popupMenuTheme: const PopupMenuThemeData( + color: _lightSecondLayer, + ), + snackBarTheme: _snackBarTheme, + tabBarTheme: TabBarTheme( + labelColor: _lightTitleColor, + unselectedLabelColor: Colors.black54, + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + primary: _lightLabelColor, ), ), + tooltipTheme: _tooltipTheme, + ); + + static final _darkThemeTypo = _typography.white; + static final _darkTitleColor = _darkThemeTypo.titleMedium!.color!; + static final _darkBodyColor = _darkThemeTypo.bodyMedium!.color!; + static final _darkLabelColor = _darkThemeTypo.labelMedium!.color!; + static const _darkFirstLayer = Color(0xFF212121); // aka `Colors.grey[900]` + static const _darkSecondLayer = Color(0xFF363636); + static const _darkThirdLayer = Color(0xFF424242); // aka `Colors.grey[800]` + + static final darkTheme = ThemeData( colorScheme: ColorScheme.dark( primary: _accentColor, secondary: _accentColor, - // surface color is used as background for the date picker header + // surface color is used by the date/time pickers surface: Colors.grey.shade800, - onPrimary: Colors.white, - onSecondary: Colors.white, + onPrimary: _darkBodyColor, + onSecondary: _darkBodyColor, + ), + brightness: Brightness.dark, + // `canvasColor` is used by `Drawer`, `DropdownButton` and `ExpansionTileCard` + canvasColor: _darkSecondLayer, + scaffoldBackgroundColor: _darkFirstLayer, + // `cardColor` is used by `ExpansionPanel` + cardColor: _darkSecondLayer, + dialogBackgroundColor: _darkSecondLayer, + indicatorColor: _accentColor, + toggleableActiveColor: _accentColor, + typography: _typography, + appBarTheme: AppBarTheme( + backgroundColor: _darkFirstLayer, + // `foregroundColor` is used by icons + foregroundColor: _darkTitleColor, + // `titleTextStyle.color` is used by text + titleTextStyle: _appBarTitleTextStyle.copyWith(color: _darkTitleColor), + systemOverlayStyle: SystemUiOverlayStyle.light, ), - snackBarTheme: SnackBarThemeData( + popupMenuTheme: const PopupMenuThemeData( + color: _darkSecondLayer, + ), + snackBarTheme: _snackBarTheme.copyWith( backgroundColor: Colors.grey.shade800, - actionTextColor: _accentColor, - contentTextStyle: const TextStyle( - color: Colors.white, + contentTextStyle: TextStyle( + color: _darkBodyColor, ), - behavior: SnackBarBehavior.floating, + ), + tabBarTheme: TabBarTheme( + labelColor: _darkTitleColor, ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - primary: Colors.white, + primary: _darkLabelColor, ), ), + tooltipTheme: _tooltipTheme, ); + + static const _blackFirstLayer = Colors.black; + static const _blackSecondLayer = Color(0xFF212121); // aka `Colors.grey[900]` + static const _blackThirdLayer = Color(0xFF303030); // aka `Colors.grey[850]` + + static final blackTheme = darkTheme.copyWith( + // `canvasColor` is used by `Drawer`, `DropdownButton` and `ExpansionTileCard` + canvasColor: _blackSecondLayer, + scaffoldBackgroundColor: _blackFirstLayer, + // `cardColor` is used by `ExpansionPanel` + cardColor: _blackSecondLayer, + dialogBackgroundColor: _blackSecondLayer, + appBarTheme: darkTheme.appBarTheme.copyWith( + backgroundColor: _blackFirstLayer, + ), + popupMenuTheme: darkTheme.popupMenuTheme.copyWith( + color: _blackSecondLayer, + ), + ); + + static Color overlayBackgroundColor({ + required Brightness brightness, + required bool blurred, + }) { + switch (brightness) { + case Brightness.dark: + return blurred ? Colors.black26 : Colors.black45; + case Brightness.light: + return blurred ? Colors.white54 : const Color(0xCCFFFFFF); + } + } + + static Color thirdLayerColor(BuildContext context) { + final isBlack = context.select((v) => v.themeBrightness == AvesThemeBrightness.black); + if (isBlack) { + return _blackThirdLayer; + } else { + switch (Theme.of(context).brightness) { + case Brightness.dark: + return _darkThirdLayer; + case Brightness.light: + return _lightThirdLayer; + } + } + } } diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index 56332e4c1..2f5db0ce1 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -4,6 +4,12 @@ extension ExtraMapNullableKey on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v]!}; } +extension ExtraMapNullableValue on Map { + Map whereNotNullValue() => {for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!}; +} + extension ExtraMapNullableKeyValue on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v]}; + + Map whereNotNullValue() => {for (var kv in entries.where((kv) => kv.value != null)) kv.key: kv.value!}; } diff --git a/lib/utils/color_utils.dart b/lib/utils/color_utils.dart deleted file mode 100644 index d9842ead6..000000000 --- a/lib/utils/color_utils.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/material.dart'; - -final Map _stringColors = {}; - -Color stringToColor(String string, {double saturation = .8, double lightness = .6}) { - var color = _stringColors[string]; - if (color == null) { - final hash = string.codeUnits.fold(0, (prev, el) => prev = el + ((prev << 5) - prev)); - final hue = (hash % 360).toDouble(); - color = HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor(); - _stringColors[string] = color; - } - return color; -} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 9d8266c28..8cdfa25cc 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -111,6 +111,11 @@ class Constants { license: 'MIT', sourceUrl: 'https://github.com/deckerst/fijkplayer', ), + Dependency( + name: 'Flutter Display Mode', + license: 'MIT', + sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode', + ), Dependency( name: 'Google API Availability', license: 'MIT', diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index f87841ab4..3e69444b5 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -2,7 +2,9 @@ import 'package:aves/widgets/about/app_ref.dart'; import 'package:aves/widgets/about/bug_report.dart'; import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; class AboutPage extends StatelessWidget { @@ -12,30 +14,36 @@ class AboutPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(context.l10n.aboutPageTitle), - ), - body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.only(top: 16), - sliver: SliverList( - delegate: SliverChildListDelegate( - const [ - AppReference(), - Divider(), - BugReport(), - Divider(), - AboutCredits(), - Divider(), - ], + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.aboutPageTitle), + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 16), + sliver: SliverList( + delegate: SliverChildListDelegate( + const [ + AppReference(), + Divider(), + BugReport(), + Divider(), + AboutCredits(), + Divider(), + ], + ), + ), ), - ), + const Licenses(), + const BottomPaddingSliver(), + ], ), - const Licenses(), - ], + ), ), ), ); diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart index 2f401185e..b7d1f253e 100644 --- a/lib/widgets/about/app_ref.dart +++ b/lib/widgets/about/app_ref.dart @@ -53,7 +53,7 @@ class _AppReferenceState extends State { mainAxisSize: MainAxisSize.min, children: [ AvesLogo( - size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.25, + size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.3, ), const SizedBox(width: 8), Text( diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart index 592486d78..4b77272c3 100644 --- a/lib/widgets/about/bug_report.dart +++ b/lib/widgets/about/bug_report.dart @@ -4,9 +4,11 @@ import 'dart:typed_data'; import 'package:aves/app_flavor.dart'; import 'package:aves/flutter_version.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -72,16 +74,19 @@ class _BugReportState extends State with FeedbackMixin { builder: (context, snapshot) { final info = snapshot.data; if (info == null) return const SizedBox(); + + final theme = Theme.of(context); return Container( decoration: BoxDecoration( - color: Colors.grey.shade800, + color: theme.cardColor, border: Border.all( - color: Colors.white, + color: theme.colorScheme.onPrimary, ), borderRadius: const BorderRadius.all(Radius.circular(8)), ), constraints: const BoxConstraints(maxHeight: 100), margin: const EdgeInsets.symmetric(vertical: 8), + clipBehavior: Clip.antiAlias, child: Theme( data: Theme.of(context).copyWith( scrollbarTheme: const ScrollbarThemeData( @@ -115,13 +120,14 @@ class _BugReportState extends State with FeedbackMixin { ), isExpanded: _showInstructions, canTapOnHeader: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: Colors.transparent, ), ], ); } Widget _buildStep(int step, String text, String buttonText, VoidCallback onPressed) { + final isMonochrome = settings.themeColorMode == AvesThemeColorMode.monochrome; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -130,7 +136,7 @@ class _BugReportState extends State with FeedbackMixin { padding: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: Theme.of(context).colorScheme.secondary, + color: isMonochrome ? context.select((v) => v.neutral) : Theme.of(context).colorScheme.secondary, width: AvesFilterChip.outlineWidth, )), shape: BoxShape.circle, @@ -162,7 +168,7 @@ class _BugReportState extends State with FeedbackMixin { 'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}', 'System locales: ${WidgetsBinding.instance!.window.locales.join(', ')}', - 'Aves locale: ${settings.locale} -> ${settings.appliedLocale}', + 'Aves locale: ${settings.locale ?? 'system'} -> ${settings.appliedLocale}', ].join('\n'); } diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 18e593d42..103d8c90d 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -1,5 +1,6 @@ import 'package:aves/app_flavor.dart'; import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -40,6 +41,7 @@ class _LicensesState extends State { @override Widget build(BuildContext context) { + final colors = context.watch(); return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 8), sliver: SliverList( @@ -49,25 +51,25 @@ class _LicensesState extends State { const SizedBox(height: 16), AvesExpansionTile( title: context.l10n.aboutLicensesAndroidLibraries, - color: BrandColors.android, + highlightColor: colors.fromBrandColor(BrandColors.android), expandedNotifier: _expandedNotifier, children: _platform.map((package) => LicenseRow(package: package)).toList(), ), AvesExpansionTile( title: context.l10n.aboutLicensesFlutterPlugins, - color: BrandColors.flutter, + highlightColor: colors.fromBrandColor(BrandColors.flutter), expandedNotifier: _expandedNotifier, children: _flutterPlugins.map((package) => LicenseRow(package: package)).toList(), ), AvesExpansionTile( title: context.l10n.aboutLicensesFlutterPackages, - color: BrandColors.flutter, + highlightColor: colors.fromBrandColor(BrandColors.flutter), expandedNotifier: _expandedNotifier, children: _flutterPackages.map((package) => LicenseRow(package: package)).toList(), ), AvesExpansionTile( title: context.l10n.aboutLicensesDartPackages, - color: BrandColors.flutter, + highlightColor: colors.fromBrandColor(BrandColors.flutter), expandedNotifier: _expandedNotifier, children: _dartPackages.map((package) => LicenseRow(package: package)).toList(), ), diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index a1fcd40d9..46adbc61a 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -6,13 +6,17 @@ import 'package:aves/app_mode.dart'; import 'package:aves/l10n/l10n.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/enums/display_refresh_rate_mode.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/screen_on.dart'; +import 'package:aves/model/settings/enums/theme_brightness.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/accessibility_service.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/themes.dart'; @@ -101,11 +105,16 @@ class _AvesAppState extends State with WidgetsBindingObserver { : Scaffold( body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), ); - return Selector>( - selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true), + return Selector>( + selector: (context, s) => Tuple3( + s.locale, + s.initialized ? s.accessibilityAnimations.animate : true, + s.initialized ? s.themeBrightness : AvesThemeBrightness.system, + ), builder: (context, s, child) { final settingsLocale = s.item1; final areAnimationsEnabled = s.item2; + final themeBrightness = s.item3; return MaterialApp( navigatorKey: _navigatorKey, home: home, @@ -126,11 +135,15 @@ class _AvesAppState extends State with WidgetsBindingObserver { child: child!, ); } - return child!; + return AvesColorsProvider( + child: child!, + ); + // return child!; }, onGenerateTitle: (context) => context.l10n.appName, - darkTheme: Themes.darkTheme, - themeMode: ThemeMode.dark, + theme: Themes.lightTheme, + darkTheme: themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme : Themes.darkTheme, + themeMode: themeBrightness.appThemeMode, locale: settingsLocale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -188,6 +201,8 @@ class _AvesAppState extends State with WidgetsBindingObserver { // save IDs of entries visible at the top of the collection page with current layout settings void _saveTopEntries() { + if (!settings.initialized) return; + final stopwatch = Stopwatch()..start(); final screenSize = window.physicalSize / window.devicePixelRatio; var tileExtent = settings.getTileExtent(CollectionPage.routeName); @@ -228,9 +243,8 @@ class _AvesAppState extends State with WidgetsBindingObserver { } } - void applyKeepScreenOn() { - settings.keepScreenOn.apply(); - } + void applyDisplayRefreshRateMode() => settings.displayRefreshRateMode.apply(); + void applyKeepScreenOn() => settings.keepScreenOn.apply(); void applyIsRotationLocked() { if (!settings.isRotationLocked) { @@ -239,9 +253,11 @@ class _AvesAppState extends State with WidgetsBindingObserver { } settings.updateStream.where((event) => event.key == Settings.isInstalledAppAccessAllowedKey).listen((_) => applyIsInstalledAppAccessAllowed()); + settings.updateStream.where((event) => event.key == Settings.displayRefreshRateModeKey).listen((_) => applyDisplayRefreshRateMode()); settings.updateStream.where((event) => event.key == Settings.keepScreenOnKey).listen((_) => applyKeepScreenOn()); settings.updateStream.where((event) => event.key == Settings.platformAccelerometerRotationKey).listen((_) => applyIsRotationLocked()); + applyDisplayRefreshRateMode(); applyKeepScreenOn(); applyIsRotationLocked(); } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 773b54a4f..1a4860ef0 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -361,27 +361,38 @@ class _CollectionAppBarState extends State with SingleTickerPr ); Widget buildItem(EntrySetAction action) => Expanded( - child: PopupMenuItem( - value: action, - enabled: canApply(action), - child: Tooltip( - message: action.getText(context), - child: Center(child: action.getIcon()), + child: Material( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: PopupMenuItem( + value: action, + enabled: canApply(action), + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), ), ), ); return PopupMenuItem( - child: Row( - children: [ - buildDivider(), - buildItem(EntrySetAction.rotateCCW), - buildDivider(), - buildItem(EntrySetAction.rotateCW), - buildDivider(), - buildItem(EntrySetAction.flip), - buildDivider(), - ], + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Row( + children: [ + buildDivider(), + buildItem(EntrySetAction.rotateCCW), + buildDivider(), + buildItem(EntrySetAction.rotateCW), + buildDivider(), + buildItem(EntrySetAction.flip), + buildDivider(), + ], + ), ), ); } @@ -441,6 +452,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.restore: case EntrySetAction.copy: case EntrySetAction.move: + case EntrySetAction.rename: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 629b52c27..64cd0cb8e 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -70,6 +70,7 @@ class _CollectionGridState extends State { extentMin: CollectionGrid.extentMin, extentMax: CollectionGrid.extentMax, spacing: CollectionGrid.spacing, + horizontalPadding: 2, ); return TileExtentControllerProvider( controller: _tileExtentController!, @@ -90,12 +91,13 @@ class _CollectionGridContent extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), builder: (context, thumbnailExtent, child) { - return Selector>( - selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), + return Selector>( + selector: (context, c) => Tuple4(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding), builder: (context, c, child) { final scrollableWidth = c.item1; final columnCount = c.item2; final tileSpacing = c.item3; + final horizontalPadding = c.item4; return GridTheme( extent: thumbnailExtent, child: EntryListDetailsTheme( @@ -117,6 +119,7 @@ class _CollectionGridContent extends StatelessWidget { tileLayout: tileLayout, columnCount: columnCount, spacing: tileSpacing, + horizontalPadding: horizontalPadding, tileExtent: thumbnailExtent, tileBuilder: (entry) => AnimatedBuilder( animation: favourites, @@ -242,7 +245,9 @@ class _CollectionScaler extends StatelessWidget { @override Widget build(BuildContext context) { - final tileSpacing = context.select((controller) => controller.spacing); + final metrics = context.select>((v) => Tuple2(v.spacing, v.horizontalPadding)); + final tileSpacing = metrics.item1; + final horizontalPadding = metrics.item2; return GridScaleGestureDetector( scrollableKey: scrollableKey, tileLayout: tileLayout, @@ -253,6 +258,7 @@ class _CollectionScaler extends StatelessWidget { tileCenter: center, tileSize: tileSize, spacing: tileSpacing, + horizontalPadding: horizontalPadding, borderWidth: DecoratedThumbnail.borderWidth, borderRadius: Radius.zero, color: DecoratedThumbnail.borderColor, diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 463053562..405fd7daa 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,11 +1,15 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; @@ -21,11 +25,15 @@ import 'package:provider/provider.dart'; class CollectionPage extends StatefulWidget { static const routeName = '/collection'; - final CollectionLens collection; + final CollectionSource source; + final Set? filters; + final bool Function(AvesEntry element)? highlightTest; const CollectionPage({ Key? key, - required this.collection, + required this.source, + required this.filters, + this.highlightTest, }) : super(key: key); @override @@ -34,17 +42,23 @@ class CollectionPage extends StatefulWidget { class _CollectionPageState extends State { final List _subscriptions = []; - - CollectionLens get collection => widget.collection; + late CollectionLens _collection; @override void initState() { + // do not seed this widget with the collection, but control its lifecycle here instead, + // as the collection properties may change and they should not be reset by a widget update (e.g. with theme change) + _collection = CollectionLens( + source: widget.source, + filters: widget.filters, + ); super.initState(); _subscriptions.add(settings.updateStream.where((event) => event.key == Settings.enableBinKey).listen((_) { if (!settings.enableBin) { - collection.removeFilter(TrashFilter.instance); + _collection.removeFilter(TrashFilter.instance); } })); + WidgetsBinding.instance!.addPostFrameCallback((_) => _checkInitHighlight()); } @override @@ -52,13 +66,13 @@ class _CollectionPageState extends State { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); - collection.dispose(); + _collection.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; + final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( @@ -79,7 +93,7 @@ class _CollectionPageState extends State { child: SafeArea( bottom: false, child: ChangeNotifierProvider.value( - value: collection, + value: _collection, child: const CollectionGrid( // key is expected by test driver key: Key('collection-grid'), @@ -93,9 +107,21 @@ class _CollectionPageState extends State { ), ), ), - drawer: AppDrawer(currentCollection: collection), + drawer: AppDrawer(currentCollection: _collection), resizeToAvoidBottomInset: false, ), ); } + + Future _checkInitHighlight() async { + final highlightTest = widget.highlightTest; + if (highlightTest == null) return; + + final delayDuration = context.read().staggeredAnimationPageTarget; + await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); + final targetEntry = _collection.sortedEntries.firstWhereOrNull(highlightTest); + if (targetEntry != null) { + context.read().trackItem(targetEntry, highlightItem: targetEntry); + } + } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 6c6f8766b..57225c150 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -8,6 +8,8 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/naming_pattern.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/enums/enums.dart'; @@ -18,6 +20,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -27,11 +30,13 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -77,6 +82,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.share: case EntrySetAction.copy: case EntrySetAction.move: + case EntrySetAction.rename: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: @@ -126,6 +132,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.restore: case EntrySetAction.copy: case EntrySetAction.move: + case EntrySetAction.rename: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: @@ -184,6 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.move: _move(context, moveType: MoveType.move); break; + case EntrySetAction.rename: + _rename(context); + break; case EntrySetAction.toggleFavourite: _toggleFavourite(context); break; @@ -197,7 +207,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _flip(context); break; case EntrySetAction.editDate: - _editDate(context); + editDate(context); break; case EntrySetAction.editLocation: _editLocation(context); @@ -214,6 +224,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } } + void _leaveSelectionMode(BuildContext context) { + final selection = context.read?>(); + selection?.browse(); + } + Set _getTargetItems(BuildContext context) { final selection = context.read>(); final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read().sortedEntries); @@ -234,20 +249,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final collection = context.read(); collection.source.analyze(controller, entries: entries); - final selection = context.read>(); - selection.browse(); - } - - Future _toggleFavourite(BuildContext context) async { - final entries = _getTargetItems(context); - if (entries.every((entry) => entry.isFavourite)) { - await favourites.removeEntries(entries); - } else { - await favourites.add(entries); - } - - final selection = context.read>(); - selection.browse(); + _leaveSelectionMode(context); } Future _delete(BuildContext context) async { @@ -264,12 +266,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final selectionDirs = entries.map((e) => e.directory).whereNotNull().toSet(); final todoCount = entries.length; - if (!(await showConfirmationDialog( + if (!await showConfirmationDialog( context: context, - type: ConfirmationDialog.delete, + type: ConfirmationDialog.deleteForever, message: l10n.deleteEntriesConfirmationDialogMessage(todoCount), confirmationButtonLabel: l10n.deleteButtonLabel, - ))) return; + )) return; if (!pureTrash && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: entries)) return; @@ -298,23 +300,56 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware }, ); - final selection = context.read>(); - selection.browse(); + _leaveSelectionMode(context); } Future _move(BuildContext context, {required MoveType moveType}) async { final entries = _getTargetItems(context); await move(context, moveType: moveType, entries: entries); - final selection = context.read>(); - selection.browse(); + _leaveSelectionMode(context); + } + + Future _rename(BuildContext context) async { + final entries = _getTargetItems(context).toList(); + + final pattern = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: RenameEntrySetPage.routeName), + builder: (context) => RenameEntrySetPage( + entries: entries, + ), + ), + ); + if (pattern == null) return; + + final entriesToNewName = Map.fromEntries(entries.mapIndexed((index, entry) { + final newName = pattern.apply(entry, index); + return MapEntry(entry, '$newName${entry.extension}'); + })).whereNotNullValue(); + await rename(context, entriesToNewName: entriesToNewName, persist: true); + + _leaveSelectionMode(context); + } + + Future _toggleFavourite(BuildContext context) async { + final entries = _getTargetItems(context); + if (entries.every((entry) => entry.isFavourite)) { + await favourites.removeEntries(entries); + } else { + await favourites.add(entries); + } + + _leaveSelectionMode(context); } Future _edit( BuildContext context, Set todoItems, - Future> Function(AvesEntry entry) op, - ) async { + Future> Function(AvesEntry entry) op, { + bool showResult = true, + }) async { final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); final todoCount = todoItems.length; @@ -355,19 +390,20 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } })); - final l10n = context.l10n; - final successCount = successOps.length; - if (successCount < todoCount) { - final count = todoCount - successCount; - showFeedback(context, l10n.collectionEditFailureFeedback(count)); - } else { - final count = editedOps.length; - showFeedback(context, l10n.collectionEditSuccessFeedback(count)); + if (showResult) { + final l10n = context.l10n; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; + showFeedback(context, l10n.collectionEditFailureFeedback(count)); + } else { + final count = editedOps.length; + showFeedback(context, l10n.collectionEditSuccessFeedback(count)); + } } }, ); - final selection = context.read>(); - selection.browse(); + _leaveSelectionMode(context); } Future?> _getEditableTargetItems( @@ -405,82 +441,85 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (confirmed == null || !confirmed) return null; // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation); + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); return supported; } Future _rotate(BuildContext context, {required bool clockwise}) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); + if (entries == null || entries.isEmpty) return; - await _edit(context, todoItems, (entry) => entry.rotate(clockwise: clockwise)); + await _edit(context, entries, (entry) => entry.rotate(clockwise: clockwise)); } Future _flip(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); + if (entries == null || entries.isEmpty) return; - await _edit(context, todoItems, (entry) => entry.flip()); + await _edit(context, entries, (entry) => entry.flip()); } - Future _editDate(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditDate); - if (todoItems == null || todoItems.isEmpty) return; + Future editDate(BuildContext context, {Set? entries, DateModifier? modifier, bool showResult = true}) async { + entries ??= await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditDate); + if (entries == null || entries.isEmpty) return; - final modifier = await selectDateModifier(context, todoItems); + if (modifier == null) { + final collection = context.read(); + modifier = await selectDateModifier(context, entries, collection); + } if (modifier == null) return; - await _edit(context, todoItems, (entry) => entry.editDate(modifier)); + await _edit(context, entries, (entry) => entry.editDate(modifier!), showResult: showResult); } Future _editLocation(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditLocation); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditLocation); + if (entries == null || entries.isEmpty) return; final collection = context.read(); - final location = await selectLocation(context, todoItems, collection); + final location = await selectLocation(context, entries, collection); if (location == null) return; - await _edit(context, todoItems, (entry) => entry.editLocation(location)); + await _edit(context, entries, (entry) => entry.editLocation(location)); } Future _editRating(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating); + if (entries == null || entries.isEmpty) return; - final rating = await selectRating(context, todoItems); + final rating = await selectRating(context, entries); if (rating == null) return; - await _edit(context, todoItems, (entry) => entry.editRating(rating)); + await _edit(context, entries, (entry) => entry.editRating(rating)); } Future _editTags(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTags); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTags); + if (entries == null || entries.isEmpty) return; - final newTagsByEntry = await selectTags(context, todoItems); + final newTagsByEntry = await selectTags(context, entries); if (newTagsByEntry == null) return; // only process modified items - todoItems.removeWhere((entry) { + entries.removeWhere((entry) { final newTags = newTagsByEntry[entry] ?? entry.tags; final currentTags = entry.tags; return newTags.length == currentTags.length && newTags.every(currentTags.contains); }); - if (todoItems.isEmpty) return; + if (entries.isEmpty) return; - await _edit(context, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!)); + await _edit(context, entries, (entry) => entry.editTags(newTagsByEntry[entry]!)); } Future _removeMetadata(BuildContext context) async { - final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRemoveMetadata); - if (todoItems == null || todoItems.isEmpty) return; + final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRemoveMetadata); + if (entries == null || entries.isEmpty) return; - final types = await selectMetadataToRemove(context, todoItems); + final types = await selectMetadataToRemove(context, entries); if (types == null || types.isEmpty) return; - await _edit(context, todoItems, (entry) => entry.removeMetadata(types)); + await _edit(context, entries, (entry) => entry.removeMetadata(types)); } void _goToMap(BuildContext context) { diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index fbce99fd0..7cb21b09b 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -20,25 +20,20 @@ class AlbumSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { Widget? albumIcon; - if (directory != null) { - albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!); + final _directory = directory; + if (_directory != null) { + albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: _directory); if (albumIcon != null) { albumIcon = RepaintBoundary( - child: Material( - type: MaterialType.circle, - elevation: 3, - color: Colors.transparent, - shadowColor: Colors.black, - child: albumIcon, - ), + child: albumIcon, ); } } return SectionHeader( - sectionKey: EntryAlbumSectionKey(directory), + sectionKey: EntryAlbumSectionKey(_directory), leading: albumIcon, title: albumName ?? context.l10n.sectionUnknown, - trailing: directory != null && androidFileUtils.isOnRemovableStorage(directory!) + trailing: _directory != null && androidFileUtils.isOnRemovableStorage(_directory) ? const Icon( AIcons.removableStorage, size: 16, diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart index 98d2a6636..608c9ea11 100644 --- a/lib/widgets/collection/grid/list_details.dart +++ b/lib/widgets/collection/grid/list_details.dart @@ -25,7 +25,7 @@ class EntryListDetails extends StatelessWidget { return Container( padding: EntryListDetailsTheme.contentPadding, foregroundDecoration: BoxDecoration( - border: Border(top: AvesBorder.straightSide), + border: Border(top: AvesBorder.straightSide(context)), ), margin: EntryListDetailsTheme.contentMargin, child: IconTheme.merge( diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart index 528dce550..56f055c43 100644 --- a/lib/widgets/collection/grid/section_layout.dart +++ b/lib/widgets/collection/grid/section_layout.dart @@ -16,6 +16,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider selectDateModifier(BuildContext context, Set entries) async { + Future selectDateModifier(BuildContext context, Set entries, CollectionLens? collection) async { if (entries.isEmpty) return null; final modifier = await showDialog( context: context, builder: (context) => EditEntryDateDialog( entry: entries.first, + collection: collection, ), ); return modifier; diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 702a2d9c6..3536b49b5 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -7,7 +7,10 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; @@ -16,6 +19,7 @@ import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; @@ -42,12 +46,12 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final l10n = context.l10n; if (toBin) { - if (!(await showConfirmationDialog( + if (!await showConfirmationDialog( context: context, type: ConfirmationDialog.moveToBin, message: l10n.binEntriesConfirmationDialogMessage(todoCount), confirmationButtonLabel: l10n.deleteButtonLabel, - ))) return; + )) return; } final entriesByDestination = >{}; @@ -108,6 +112,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { } } + if ({MoveType.move, MoveType.copy}.contains(moveType) && !await _checkUndatedItems(context, entries)) return; + final source = context.read(); source.pauseMonitoring(); final opId = mediaFileService.newOpId; @@ -143,50 +149,45 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); } else { final count = movedOps.length; - final appMode = context.read>().value; + final appMode = context.read?>()?.value; SnackBarAction? action; if (count > 0 && appMode == AppMode.main && !toBin) { action = SnackBarAction( label: l10n.showButtonLabel, onPressed: () async { - late CollectionLens targetCollection; + final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); - final highlightInfo = context.read(); final collection = context.read(); - if (collection != null) { - targetCollection = collection; - } if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) { - targetCollection = CollectionLens( - source: source, - filters: collection?.filters.where((f) => f != TrashFilter.instance).toSet(), - ); + final targetFilters = collection?.filters.where((f) => f != TrashFilter.instance).toSet() ?? {}; // we could simply add the filter to the current collection // but navigating makes the change less jarring if (destinationAlbums.length == 1) { final destinationAlbum = destinationAlbums.single; - final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); - targetCollection.addFilter(filter); + targetFilters.removeWhere((f) => f is AlbumFilter); + targetFilters.add(AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))); } unawaited(Navigator.pushAndRemoveUntil( context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: targetCollection, + source: source, + filters: targetFilters, + highlightTest: highlightTest, ), ), (route) => false, )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration); - } - await Future.delayed(Durations.highlightScrollInitDelay); - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); - final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); - if (targetEntry != null) { - highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); + } else { + // track in current page, without navigation + await Future.delayed(Durations.highlightScrollInitDelay); + final targetEntry = collection.sortedEntries.firstWhereOrNull(highlightTest); + if (targetEntry != null) { + context.read().trackItem(targetEntry, highlightItem: targetEntry); + } } }, ); @@ -201,4 +202,109 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { }, ); } + + Future rename( + BuildContext context, { + required Map entriesToNewName, + required bool persist, + VoidCallback? onSuccess, + }) async { + final entries = entriesToNewName.keys.toSet(); + final todoCount = entries.length; + assert(todoCount > 0); + + if (!await checkStoragePermission(context, entries)) return; + + if (!await _checkUndatedItems(context, entries)) return; + + final source = context.read(); + source.pauseMonitoring(); + final opId = mediaFileService.newOpId; + await showOpReport( + context: context, + opStream: mediaFileService.rename( + opId: opId, + entriesToNewName: entriesToNewName, + ), + itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), + onDone: (processed) async { + final successOps = processed.where((e) => e.success).toSet(); + final movedOps = successOps.where((e) => !e.skipped).toSet(); + await source.updateAfterRename( + todoEntries: entries, + movedOps: movedOps, + persist: persist, + ); + source.resumeMonitoring(); + + final l10n = context.l10n; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; + showFeedback(context, l10n.collectionRenameFailureFeedback(count)); + } else { + final count = movedOps.length; + showFeedback(context, l10n.collectionRenameSuccessFeedback(count)); + onSuccess?.call(); + } + }, + ); + } + + Future _checkUndatedItems(BuildContext context, Set entries) async { + final undatedItems = entries.where((entry) { + if (!entry.isCatalogued) return false; + final dateMillis = entry.catalogMetadata?.dateMillis; + return dateMillis == null || dateMillis == 0; + }).toSet(); + if (undatedItems.isNotEmpty) { + if (!await showConfirmationDialog( + context: context, + type: ConfirmationDialog.moveUndatedItems, + delegate: MoveUndatedConfirmationDialogDelegate(), + confirmationButtonLabel: context.l10n.continueButtonLabel, + )) return false; + + if (settings.setMetadataDateBeforeFileOp) { + final entriesToDate = undatedItems.where((entry) => entry.canEditDate).toSet(); + if (entriesToDate.isNotEmpty) { + await EntrySetActionDelegate().editDate( + context, + entries: entriesToDate, + modifier: DateModifier.copyField(DateFieldSource.fileModifiedDate), + showResult: false, + ); + } + } + } + return true; + } +} + +class MoveUndatedConfirmationDialogDelegate extends ConfirmationDialogDelegate { + final ValueNotifier _setMetadataDate = ValueNotifier(false); + + MoveUndatedConfirmationDialogDelegate() { + _setMetadataDate.value = settings.setMetadataDateBeforeFileOp; + } + + @override + List build(BuildContext context) => [ + Padding( + padding: const EdgeInsets.all(16) + const EdgeInsets.only(top: 8), + child: Text(context.l10n.moveUndatedConfirmationDialogMessage), + ), + ValueListenableBuilder( + valueListenable: _setMetadataDate, + builder: (context, flag, child) => SwitchListTile( + value: flag, + onChanged: (v) => _setMetadataDate.value = v, + title: Text(context.l10n.moveUndatedConfirmationDialogSetDate), + ), + ), + ]; + + @override + void apply() => settings.setMetadataDateBeforeFileOp = _setMetadataDate.value; } diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index d1c9f9e54..1b81ebd50 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -171,8 +171,8 @@ class _ReportOverlayState extends State> with SingleTickerPr Container( width: diameter + 2, height: diameter + 2, - decoration: const BoxDecoration( - color: Color(0xBB000000), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark ? const Color(0xBB000000) : const Color(0xEEFFFFFF), shape: BoxShape.circle, ), ), @@ -190,7 +190,7 @@ class _ReportOverlayState extends State> with SingleTickerPr percent: percent, lineWidth: strokeWidth, radius: diameter / 2, - backgroundColor: Colors.white24, + backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2), progressColor: progressColor, animation: animate, center: Text( @@ -270,6 +270,8 @@ class _FeedbackMessageState extends State<_FeedbackMessage> { Widget build(BuildContext context) { final text = Text(widget.message); final duration = widget.duration; + final theme = Theme.of(context); + final contentTextStyle = theme.snackBarTheme.contentTextStyle ?? ThemeData(brightness: theme.brightness).textTheme.subtitle1; return duration == null ? text : Row( @@ -286,7 +288,10 @@ class _FeedbackMessageState extends State<_FeedbackMessage> { progressColor: Colors.grey, animation: true, animationDuration: duration.inMilliseconds, - center: Text('$_remainingSecs'), + center: Text( + '$_remainingSecs', + style: contentTextStyle, + ), animateFromLastPercent: true, reverse: true, ), diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 7975f088e..9871ca7e8 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/events.dart'; @@ -62,25 +64,28 @@ class SourceStateSubtitle extends StatelessWidget { final subtitle = sourceState.getName(context.l10n); if (subtitle == null) return const SizedBox(); - final subtitleStyle = Theme.of(context).textTheme.caption!; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: subtitleStyle), - ValueListenableBuilder( - valueListenable: source.progressNotifier, - builder: (context, progress, snapshot) { - if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox(); - return Padding( - padding: const EdgeInsetsDirectional.only(start: 8), - child: Text( - '${progress.done}/${progress.total}', - style: subtitleStyle.copyWith(color: Colors.white30), - ), - ); - }, - ), - ], + final theme = Theme.of(context); + return DefaultTextStyle.merge( + style: theme.textTheme.caption!.copyWith(fontFeatures: const [FontFeature.disable('smcp')]), + child: ValueListenableBuilder( + valueListenable: source.progressNotifier, + builder: (context, progress, snapshot) { + return Text.rich( + TextSpan( + children: [ + TextSpan(text: subtitle), + if (progress.total != 0 && sourceState != SourceState.locatingCountries) ...[ + const WidgetSpan(child: SizedBox(width: 8)), + TextSpan( + text: '${progress.done}/${progress.total}', + style: TextStyle(color: theme.brightness == Brightness.dark ? Colors.white30 : Colors.black26), + ), + ] + ], + ), + ); + }, + ), ); } } diff --git a/lib/widgets/common/basic/color_list_tile.dart b/lib/widgets/common/basic/color_list_tile.dart index 92216f957..b03085c7f 100644 --- a/lib/widgets/common/basic/color_list_tile.dart +++ b/lib/widgets/common/basic/color_list_tile.dart @@ -27,7 +27,7 @@ class ColorListTile extends StatelessWidget { width: radius * 2, decoration: BoxDecoration( color: value, - border: AvesBorder.border, + border: AvesBorder.border(context), shape: BoxShape.circle, ), ), diff --git a/lib/widgets/common/basic/markdown_container.dart b/lib/widgets/common/basic/markdown_container.dart index 76110e64f..43a37964f 100644 --- a/lib/widgets/common/basic/markdown_container.dart +++ b/lib/widgets/common/basic/markdown_container.dart @@ -1,3 +1,4 @@ +import 'package:aves/widgets/common/fx/borders.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -18,9 +19,10 @@ class MarkdownContainer extends StatelessWidget { Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(horizontal: 8), - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(16)), - color: Colors.white10, + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: Border.all(color: Theme.of(context).dividerColor, width: AvesBorder.curvedBorderWidth), + borderRadius: const BorderRadius.all(Radius.circular(16)), ), constraints: const BoxConstraints(maxWidth: maxWidth), child: ClipRRect( diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart index a4984026f..8c568a77a 100644 --- a/lib/widgets/common/basic/menu.dart +++ b/lib/widgets/common/basic/menu.dart @@ -18,8 +18,13 @@ class MenuRow extends StatelessWidget { children: [ if (icon != null) Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: icon, + padding: const EdgeInsetsDirectional.only(end: 12), + child: IconTheme.merge( + data: IconThemeData( + color: ListTileTheme.of(context).iconColor, + ), + child: icon!, + ), ), Expanded(child: Text(text)), ], @@ -110,6 +115,7 @@ class _PopupMenuItemExpansionPanelState extends State entries; @@ -73,7 +74,10 @@ class _FavouriteTogglerState extends State { ), Sweeper( key: ValueKey(entries.length == 1 ? entries.first : entries.length), - builder: (context) => const Icon(AIcons.favourite, color: AColors.favourite), + builder: (context) => Icon( + AIcons.favourite, + color: context.select((v) => v.favourite), + ), toggledNotifier: isFavouriteNotifier, ), ], diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 766cd8671..2e2fe275e 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; class AvesBorder { - static const borderColor = Colors.white30; + static Color _borderColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark ? Colors.white30 : Colors.black26; // directly uses `devicePixelRatio` as it never changes, to avoid visiting ancestors via `MediaQuery` @@ -13,15 +13,15 @@ class AvesBorder { // 1 device pixel for curves is too thin static double get curvedBorderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0; - static BorderSide get straightSide => BorderSide( - color: borderColor, + static BorderSide straightSide(BuildContext context) => BorderSide( + color: _borderColor(context), width: straightBorderWidth, ); - static BorderSide get curvedSide => BorderSide( - color: borderColor, + static BorderSide curvedSide(BuildContext context) => BorderSide( + color: _borderColor(context), width: curvedBorderWidth, ); - static Border get border => Border.fromBorderSide(curvedSide); + static Border border(BuildContext context) => Border.fromBorderSide(curvedSide(context)); } diff --git a/lib/widgets/common/fx/colors.dart b/lib/widgets/common/fx/colors.dart new file mode 100644 index 000000000..0b7646f68 --- /dev/null +++ b/lib/widgets/common/fx/colors.dart @@ -0,0 +1,26 @@ +import 'package:flutter/painting.dart'; + +class MatrixColorFilters { + static const ColorFilter greyscale = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); +} diff --git a/lib/widgets/common/grid/overlay.dart b/lib/widgets/common/grid/overlay.dart index c9c111df4..4af0f83d2 100644 --- a/lib/widgets/common/grid/overlay.dart +++ b/lib/widgets/common/grid/overlay.dart @@ -44,19 +44,19 @@ class GridItemSelectionOverlay extends StatelessWidget { child: child, ); child = AnimatedContainer( - duration: duration, alignment: AlignmentDirectional.topEnd, padding: padding, decoration: BoxDecoration( color: isSelected ? Colors.black54 : Colors.transparent, borderRadius: borderRadius, ), + duration: duration, child: child, ); return child; }, ) - : const SizedBox.shrink(); + : const SizedBox(); return AnimatedSwitcher( duration: duration, child: child, diff --git a/lib/widgets/common/grid/scaling.dart b/lib/widgets/common/grid/scaling.dart index 7d99d31f2..279f3fa27 100644 --- a/lib/widgets/common/grid/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -108,19 +108,26 @@ class _GridScaleGestureDetectorState extends State _ScaleOverlay( builder: (scaledTileSize) { late final double themeExtent; - switch (widget.tileLayout) { + switch (tileLayout) { case TileLayout.grid: themeExtent = scaledTileSize.width; break; @@ -136,9 +143,10 @@ class _GridScaleGestureDetectorState extends State scaledSizeNotifier; final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; @@ -229,7 +237,8 @@ class _ScaleOverlay extends StatefulWidget { required this.builder, required this.tileLayout, required this.center, - required this.viewportWidth, + required this.xMin, + required this.xMax, required this.scaledSizeNotifier, required this.gridBuilder, }) : super(key: key); @@ -243,7 +252,13 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { Offset get center => widget.center; - double get gridWidth => widget.viewportWidth; + double get xMin => widget.xMin; + + double get xMax => widget.xMax; + + // `Color(0x00FFFFFF)` is different from `Color(0x00000000)` (or `Colors.transparent`) + // when used in gradients or lerping to it + static const transparentWhite = Color(0x00FFFFFF); @override void initState() { @@ -265,8 +280,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { final width = scaledSize.width; final height = scaledSize.height; // keep scaled thumbnail within the screen - final xMin = context.select((mq) => mq.padding.left); - final xMax = xMin + gridWidth; var dx = .0; if (center.dx - width / 2 < xMin) { dx = xMin - (center.dx - width / 2); @@ -305,28 +318,39 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { gradientCenter = center; break; case TileLayout.list: - gradientCenter = Offset(context.isRtl ? gridWidth : 0, center.dy); + gradientCenter = Offset(context.isRtl ? xMax : xMin, center.dy); break; } + final isDark = Theme.of(context).brightness == Brightness.dark; return _init ? BoxDecoration( gradient: RadialGradient( center: FractionalOffset.fromOffsetAndSize(gradientCenter, context.select((mq) => mq.size)), radius: 1, - colors: const [ - Colors.black, - Colors.black54, - ], + colors: isDark + ? const [ + Colors.black, + Colors.black54, + ] + : const [ + Colors.white, + Colors.white38, + ], ), ) - : const BoxDecoration( + : BoxDecoration( // provide dummy gradient to lerp to the other one during animation gradient: RadialGradient( - colors: [ - Colors.transparent, - Colors.transparent, - ], + colors: isDark + ? const [ + Colors.transparent, + Colors.transparent, + ] + : const [ + transparentWhite, + transparentWhite, + ], ), ); } @@ -336,7 +360,7 @@ class GridPainter extends CustomPainter { final TileLayout tileLayout; final Offset tileCenter; final Size tileSize; - final double spacing, borderWidth; + final double spacing, horizontalPadding, borderWidth; final Radius borderRadius; final Color color; final TextDirection textDirection; @@ -346,6 +370,7 @@ class GridPainter extends CustomPainter { required this.tileCenter, required this.tileSize, required this.spacing, + required this.horizontalPadding, required this.borderWidth, required this.borderRadius, required this.color, @@ -379,7 +404,7 @@ class GridPainter extends CustomPainter { case TileLayout.list: chipSize = Size.square(tileSize.shortestSide); final chipCenterToEdge = chipSize.width / 2; - chipCenter = Offset(textDirection == TextDirection.rtl ? size.width - chipCenterToEdge : chipCenterToEdge, tileCenter.dy); + chipCenter = Offset(textDirection == TextDirection.rtl ? size.width - (chipCenterToEdge + horizontalPadding) : chipCenterToEdge + horizontalPadding, tileCenter.dy); deltaColumn = 0; strokeShader = ui.Gradient.linear( tileCenter - Offset(0, chipSize.shortestSide * 3), diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index c30ac4c7d..42f2dd021 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -15,7 +15,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { final double scrollableWidth; final TileLayout tileLayout; final int columnCount; - final double spacing, tileWidth, tileHeight; + final double spacing, horizontalPadding, tileWidth, tileHeight; final Widget Function(T item) tileBuilder; final Duration tileAnimationDelay; final Widget child; @@ -26,6 +26,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { required this.tileLayout, required int columnCount, required this.spacing, + required this.horizontalPadding, required double tileWidth, required this.tileHeight, required this.tileBuilder, @@ -33,7 +34,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { required this.child, }) : assert(scrollableWidth != 0), columnCount = tileLayout == TileLayout.list ? 1 : columnCount, - tileWidth = tileLayout == TileLayout.list ? scrollableWidth : tileWidth, + tileWidth = tileLayout == TileLayout.list ? scrollableWidth - (horizontalPadding * 2) : tileWidth, super(key: key); @override @@ -98,6 +99,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { tileWidth: tileWidth, tileHeight: tileHeight, spacing: spacing, + horizontalPadding: horizontalPadding, sectionLayouts: sectionLayouts, ); } @@ -129,12 +131,15 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { ); children.add(animate ? _buildAnimation(context, itemGridIndex, item) : item); } - return _GridRow( - width: tileWidth, - height: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: children, + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: _GridRow( + width: tileWidth, + height: tileHeight, + spacing: spacing, + textDirection: Directionality.of(context), + children: children, + ), ); } @@ -168,6 +173,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); properties.add(IntProperty('columnCount', columnCount)); properties.add(DoubleProperty('spacing', spacing)); + properties.add(DoubleProperty('horizontalPadding', horizontalPadding)); properties.add(DoubleProperty('tileWidth', tileWidth)); properties.add(DoubleProperty('tileHeight', tileHeight)); properties.add(DiagnosticsProperty('showHeaders', showHeaders)); @@ -178,7 +184,7 @@ class SectionedListLayout { final Map> sections; final bool showHeaders; final int columnCount; - final double tileWidth, tileHeight, spacing; + final double tileWidth, tileHeight, spacing, horizontalPadding; final List sectionLayouts; const SectionedListLayout({ @@ -188,6 +194,7 @@ class SectionedListLayout { required this.tileWidth, required this.tileHeight, required this.spacing, + required this.horizontalPadding, required this.sectionLayouts, }); @@ -205,7 +212,7 @@ class SectionedListLayout { final row = (sectionItemIndex / columnCount).floor(); final listIndex = sectionLayout.firstIndex + 1 + row; - final left = tileWidth * column + spacing * (column - 1); + final left = horizontalPadding + tileWidth * column + spacing * (column - 1); final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileWidth, tileHeight); } @@ -225,7 +232,7 @@ class SectionedListLayout { if (dy < 0) return null; final row = dy ~/ (tileHeight + spacing); - final column = position.dx ~/ (tileWidth + spacing); + final column = max(0, position.dx - horizontalPadding) ~/ (tileWidth + spacing); final index = row * columnCount + column; if (index >= section.length) return null; diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index f1fac2a67..34c4e019d 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -8,7 +8,7 @@ class AvesExpansionTile extends StatelessWidget { final String value; final Widget? leading; final String title; - final Color? color; + final Color? highlightColor; final ValueNotifier? expandedNotifier; final bool initiallyExpanded, showHighlight; final List children; @@ -18,7 +18,7 @@ class AvesExpansionTile extends StatelessWidget { String? value, this.leading, required this.title, - this.color, + this.highlightColor, this.expandedNotifier, this.initiallyExpanded = false, this.showHighlight = true, @@ -31,7 +31,7 @@ class AvesExpansionTile extends StatelessWidget { final enabled = children.isNotEmpty == true; Widget titleChild = HighlightTitle( title: title, - color: color, + color: highlightColor, enabled: enabled, showHighlight: showHighlight, ); @@ -63,8 +63,8 @@ class AvesExpansionTile extends StatelessWidget { expandable: enabled, initiallyExpanded: initiallyExpanded, finalPadding: const EdgeInsets.symmetric(vertical: 6.0), - baseColor: Colors.grey.shade900, - expandedColor: Colors.grey[850], + baseColor: theme.scaffoldBackgroundColor, + expandedColor: theme.canvasColor, duration: animationDuration, shadowColor: theme.shadowColor, child: Column( diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index e362d4ce7..7473c4ecb 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -6,6 +6,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu.dart'; @@ -46,7 +47,6 @@ class AvesFilterChip extends StatefulWidget { final FilterCallback? onTap; final OffsetFilterCallback? onLongPress; - static const Color defaultOutlineColor = Colors.white; static const double defaultRadius = 32; static const double outlineWidth = 2; static const double minChipHeight = kMinInteractiveDimension; @@ -156,7 +156,7 @@ class _AvesFilterChipState extends State { // the existing widget FutureBuilder cycles again from the start, with a frame in `waiting` state and no data. // So we save the result of the Future to a local variable because of this specific case. _colorFuture = filter.color(context); - _outlineColor = AvesFilterChip.defaultOutlineColor; + _outlineColor = context.read().neutral; } @override @@ -270,7 +270,7 @@ class _AvesFilterChipState extends State { return DecoratedBox( decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor, + color: widget.useFilterColor ? _outlineColor : context.select((v) => v.neutral), width: AvesFilterChip.outlineWidth, )), borderRadius: borderRadius, diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index f259495e2..d6780b12c 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -27,7 +27,6 @@ class VideoIcon extends StatelessWidget { if (showDuration) { child = DefaultTextStyle( style: TextStyle( - color: Colors.grey.shade200, fontSize: gridTheme.fontSize, ), child: child, @@ -146,7 +145,6 @@ class MultiPageIcon extends StatelessWidget { ); return DefaultTextStyle( style: TextStyle( - color: Colors.grey.shade200, fontSize: context.select((t) => t.fontSize), ), child: child, @@ -167,7 +165,6 @@ class RatingIcon extends StatelessWidget { final gridTheme = context.watch(); return DefaultTextStyle( style: TextStyle( - color: Colors.grey.shade200, fontSize: gridTheme.fontSize, ), child: OverlayIcon( @@ -195,7 +192,6 @@ class TrashIcon extends StatelessWidget { return DefaultTextStyle( style: TextStyle( - color: Colors.grey.shade200, fontSize: context.select((t) => t.fontSize), ), child: child, @@ -224,8 +220,6 @@ class OverlayIcon extends StatelessWidget { final iconChild = Icon( icon, size: size, - // consistent with the color used for the text next to it - color: DefaultTextStyle.of(context).style.color, ); final iconBox = SizedBox( width: size, @@ -243,7 +237,7 @@ class OverlayIcon extends StatelessWidget { margin: margin, padding: text != null ? EdgeInsetsDirectional.only(end: size / 4) : null, decoration: BoxDecoration( - color: const Color(0xBB000000), + color: Theme.of(context).brightness == Brightness.dark ? const Color(0xAA000000) : const Color(0xCCFFFFFF), borderRadius: BorderRadius.all(Radius.circular(size)), ), child: text == null @@ -254,7 +248,11 @@ class OverlayIcon extends StatelessWidget { children: [ iconBox, const SizedBox(width: 2), - Text(text!), + Text( + text!, + // consistent with the color used for the icon next to it + style: TextStyle(color: IconTheme.of(context).color), + ), ], ), ); diff --git a/lib/widgets/common/identity/aves_logo.dart b/lib/widgets/common/identity/aves_logo.dart index b61fefa70..23fb7a04e 100644 --- a/lib/widgets/common/identity/aves_logo.dart +++ b/lib/widgets/common/identity/aves_logo.dart @@ -1,4 +1,9 @@ +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/fx/colors.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class AvesLogo extends StatelessWidget { final double size; @@ -10,14 +15,32 @@ class AvesLogo extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + + Widget child = CustomPaint( + size: Size(size / 1.4, size / 1.4), + painter: AvesLogoPainter(), + ); + if (context.select((v) => v.themeColorMode == AvesThemeColorMode.monochrome)) { + final tint = Color.lerp(theme.colorScheme.secondary, Colors.white, .5)!; + child = ColorFiltered( + colorFilter: ColorFilter.mode(tint, BlendMode.modulate), + child: ColorFiltered( + colorFilter: MatrixColorFilters.greyscale, + child: child, + ), + ); + } + return CircleAvatar( - backgroundColor: Colors.white, + backgroundColor: theme.dividerColor, radius: size / 2, - child: Padding( - padding: EdgeInsets.only(top: size / 15), - child: CustomPaint( - size: Size(size / 1.4, size / 1.4), - painter: AvesLogoPainter(), + child: CircleAvatar( + backgroundColor: Colors.white, + radius: size / 2 - AvesBorder.curvedBorderWidth, + child: Padding( + padding: EdgeInsets.only(top: size / 15), + child: child, ), ), ); diff --git a/lib/widgets/common/identity/buttons.dart b/lib/widgets/common/identity/buttons.dart index 797026f83..3897ee2d6 100644 --- a/lib/widgets/common/identity/buttons.dart +++ b/lib/widgets/common/identity/buttons.dart @@ -22,7 +22,7 @@ class AvesOutlinedButton extends StatelessWidget { ); }), foregroundColor: MaterialStateProperty.resolveWith((states) { - return states.contains(MaterialState.disabled) ? theme.disabledColor : Colors.white; + return states.contains(MaterialState.disabled) ? theme.disabledColor : theme.colorScheme.onSecondary; }), ); return icon != null diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index 7993734e5..a8976cb8d 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -1,8 +1,11 @@ import 'dart:ui'; -import 'package:aves/utils/color_utils.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class HighlightTitle extends StatelessWidget { final String title; @@ -34,18 +37,19 @@ class HighlightTitle extends StatelessWidget { @override Widget build(BuildContext context) { final style = TextStyle( - shadows: shadows, + shadows: Theme.of(context).brightness == Brightness.dark ? shadows : null, fontSize: fontSize, letterSpacing: 1.0, fontFeatures: const [FontFeature.enable('smcp')], ); + final colors = context.watch(); return Align( alignment: AlignmentDirectional.centerStart, child: Container( - decoration: showHighlight + decoration: showHighlight && context.select((v) => v.themeColorMode == AvesThemeColorMode.polychrome) ? HighlightDecoration( - color: enabled ? color ?? stringToColor(title) : disabledColor, + color: enabled ? color ?? colors.fromString(title) : disabledColor, ) : null, margin: const EdgeInsets.symmetric(vertical: 4.0), diff --git a/lib/widgets/common/map/attribution.dart b/lib/widgets/common/map/attribution.dart index afeb94a2b..9488b38cb 100644 --- a/lib/widgets/common/map/attribution.dart +++ b/lib/widgets/common/map/attribution.dart @@ -22,19 +22,20 @@ class Attribution extends StatelessWidget { case EntryMapStyle.stamenWatercolor: return _buildAttributionMarkdown(context, context.l10n.mapAttributionStamen); default: - return const SizedBox.shrink(); + return const SizedBox(); } } Widget _buildAttributionMarkdown(BuildContext context, String data) { + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.only(top: 4), child: MarkdownBody( data: data, selectable: true, styleSheet: MarkdownStyleSheet( - a: TextStyle(color: Theme.of(context).colorScheme.secondary), - p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize), + a: TextStyle(color: theme.colorScheme.secondary), + p: theme.textTheme.caption!.merge(const TextStyle(fontSize: InfoRowGroup.fontSize)), ), onTapLink: (text, href, title) async { if (href != null && await canLaunch(href)) { diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index e470a0b96..68595fd76 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -5,6 +5,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; @@ -15,7 +16,6 @@ import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; -import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; @@ -207,10 +207,10 @@ class MapOverlayButton extends StatelessWidget { enabled: blurred, child: Material( type: MaterialType.circle, - color: overlayBackgroundColor(blurred: blurred), + color: Themes.overlayBackgroundColor(brightness: Theme.of(context).brightness, blurred: blurred), child: Ink( decoration: BoxDecoration( - border: AvesBorder.border, + border: AvesBorder.border(context), shape: BoxShape.circle, ), child: Selector( @@ -278,9 +278,10 @@ class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterCh @override Widget build(BuildContext context) { final blurred = settings.enableOverlayBlurEffect; + final theme = Theme.of(context); return Theme( - data: Theme.of(context).copyWith( - scaffoldBackgroundColor: overlayBackgroundColor(blurred: blurred), + data: theme.copyWith( + scaffoldBackgroundColor: Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred), ), child: Align( alignment: Alignment.topLeft, diff --git a/lib/widgets/common/map/decorator.dart b/lib/widgets/common/map/decorator.dart index d3f6dc7b8..cafee9b0e 100644 --- a/lib/widgets/common/map/decorator.dart +++ b/lib/widgets/common/map/decorator.dart @@ -1,3 +1,4 @@ +import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -28,6 +29,10 @@ class MapDecorator extends StatelessWidget { borderRadius: mapBorderRadius, child: Container( color: mapBackground, + foregroundDecoration: BoxDecoration( + border: AvesBorder.border(context), + borderRadius: mapBorderRadius, + ), child: Stack( children: [ const GridPaper( diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index f8238f888..ddbfb9cad 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -14,12 +14,14 @@ class ImageMarker extends StatelessWidget { static const double outerBorderRadiusDim = 8; static const double outerBorderWidth = 1.5; static const double innerBorderWidth = 2; - static const outerBorderColor = Colors.white30; - static const innerBorderColor = Color(0xFF212121); static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim)); static const innerRadius = Radius.circular(outerBorderRadiusDim - outerBorderWidth); static const innerBorderRadius = BorderRadius.all(innerRadius); + static Color themedOuterBorderColor(bool isDark) => isDark ? Colors.white30 : Colors.black26; + + static Color themedInnerBorderColor(bool isDark) => isDark ? const Color(0xFF212121) : Colors.white; + const ImageMarker({ Key? key, required this.entry, @@ -46,7 +48,12 @@ class ImageMarker extends StatelessWidget { child: child, ); - const outerDecoration = BoxDecoration( + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final outerBorderColor = themedOuterBorderColor(isDark); + final innerBorderColor = themedInnerBorderColor(isDark); + + final outerDecoration = BoxDecoration( border: Border.fromBorderSide(BorderSide( color: outerBorderColor, width: outerBorderWidth, @@ -54,7 +61,7 @@ class ImageMarker extends StatelessWidget { borderRadius: outerBorderRadius, ); - const innerDecoration = BoxDecoration( + final innerDecoration = BoxDecoration( border: Border.fromBorderSide(BorderSide( color: innerBorderColor, width: innerBorderWidth, @@ -72,7 +79,7 @@ class ImageMarker extends StatelessWidget { ); if (count != null) { - const borderSide = BorderSide( + final borderSide = BorderSide( color: innerBorderColor, width: innerBorderWidth, ); @@ -82,28 +89,28 @@ class ImageMarker extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 2), decoration: ShapeDecoration( - color: Theme.of(context).colorScheme.secondary, + color: theme.colorScheme.secondary, shape: context.isRtl - ? const CustomRoundedRectangleBorder( + ? CustomRoundedRectangleBorder( leftSide: borderSide, rightSide: borderSide, topSide: borderSide, bottomSide: borderSide, topRightCornerSide: borderSide, bottomLeftCornerSide: borderSide, - borderRadius: BorderRadius.only( + borderRadius: const BorderRadius.only( topRight: innerRadius, bottomLeft: innerRadius, ), ) - : const CustomRoundedRectangleBorder( + : CustomRoundedRectangleBorder( leftSide: borderSide, rightSide: borderSide, topSide: borderSide, bottomSide: borderSide, topLeftCornerSide: borderSide, bottomRightCornerSide: borderSide, - borderRadius: BorderRadius.only( + borderRadius: const BorderRadius.only( topLeft: innerRadius, bottomRight: innerRadius, ), @@ -111,7 +118,10 @@ class ImageMarker extends StatelessWidget { ), child: Text( '$count', - style: const TextStyle(fontSize: 12), + style: const TextStyle( + fontSize: 12, + color: Colors.white, + ), ), ), ], @@ -190,17 +200,22 @@ class DotMarker extends StatelessWidget { @override Widget build(BuildContext context) { - const outerDecoration = BoxDecoration( + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final outerBorderColor = ImageMarker.themedOuterBorderColor(isDark); + final innerBorderColor = ImageMarker.themedInnerBorderColor(isDark); + + final outerDecoration = BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: ImageMarker.outerBorderColor, + color: outerBorderColor, width: ImageMarker.outerBorderWidth, )), borderRadius: outerBorderRadius, ); - const innerDecoration = BoxDecoration( + final innerDecoration = BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: ImageMarker.innerBorderColor, + color: innerBorderColor, width: ImageMarker.innerBorderWidth, )), borderRadius: innerBorderRadius, @@ -216,7 +231,7 @@ class DotMarker extends StatelessWidget { child: Container( width: diameter, height: diameter, - color: Theme.of(context).colorScheme.secondary, + color: theme.colorScheme.secondary, ), ), ), diff --git a/lib/widgets/common/sliver_app_bar_title.dart b/lib/widgets/common/sliver_app_bar_title.dart index 0ed370ba2..5d4f786eb 100644 --- a/lib/widgets/common/sliver_app_bar_title.dart +++ b/lib/widgets/common/sliver_app_bar_title.dart @@ -14,7 +14,7 @@ class SliverAppBarTitleWrapper extends StatelessWidget { @override Widget build(BuildContext context) { final toolbarOpacity = context.dependOnInheritedWidgetOfExactType()!.toolbarOpacity; - final baseColor = (DefaultTextStyle.of(context).style.color ?? Theme.of(context).primaryTextTheme.headline6!.color!); + final baseColor = (DefaultTextStyle.of(context).style.color ?? Theme.of(context).textTheme.headline6!.color!); return DefaultTextStyle.merge( style: TextStyle(color: baseColor.withOpacity(toolbarOpacity)), child: child, diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index f3ae1a39a..558ae4153 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -179,9 +179,12 @@ class _ThumbnailImageState extends State { Color? _loadingBackgroundColor; - Color get loadingBackgroundColor { + Color loadingBackgroundColor(BuildContext context) { if (_loadingBackgroundColor == null) { - final rgb = 0x30 + entry.uri.hashCode % 0x20; + var rgb = 0x30 + entry.uri.hashCode % 0x20; + if (Theme.of(context).brightness == Brightness.light) { + rgb = 0xFF - rgb; + } _loadingBackgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb); } return _loadingBackgroundColor!; @@ -201,7 +204,7 @@ class _ThumbnailImageState extends State { final imageInfo = _lastImageInfo; Widget image = imageInfo == null ? Container( - color: widget.showLoadingBackground ? loadingBackgroundColor : Colors.transparent, + color: widget.showLoadingBackground ? loadingBackgroundColor(context) : Colors.transparent, width: extent, height: extent, ) diff --git a/lib/widgets/common/tile_extent_controller.dart b/lib/widgets/common/tile_extent_controller.dart index 5a055a2ed..99178e549 100644 --- a/lib/widgets/common/tile_extent_controller.dart +++ b/lib/widgets/common/tile_extent_controller.dart @@ -7,7 +7,7 @@ import 'package:flutter/widgets.dart'; class TileExtentController { final String settingsRouteKey; final int columnCountMin, columnCountDefault; - final double spacing, extentMin, extentMax; + final double extentMin, extentMax, spacing, horizontalPadding; final ValueNotifier extentNotifier = ValueNotifier(0); late double userPreferredExtent; @@ -22,6 +22,7 @@ class TileExtentController { required this.extentMin, required this.extentMax, required this.spacing, + required this.horizontalPadding, }) { userPreferredExtent = settings.getTileExtent(settingsRouteKey); settings.addListener(_onSettingsChanged); @@ -68,11 +69,11 @@ class TileExtentController { return newExtent; } - double _extentMax() => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin); + double _extentMax() => min(extentMax, (viewportSize.shortestSide - (horizontalPadding * 2) - spacing * (columnCountMin - 1)) / columnCountMin); - double _columnCountForExtent(double extent) => (viewportSize.width + spacing) / (extent + spacing); + double _columnCountForExtent(double extent) => (viewportSize.width - (horizontalPadding * 2) + spacing) / (extent + spacing); - double _extentForColumnCount(int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount; + double _extentForColumnCount(int columnCount) => (viewportSize.width - (horizontalPadding * 2) - spacing * (columnCount - 1)) / columnCount; int _effectiveColumnCountMin() => _columnCountForExtent(_extentMax()).ceil(); @@ -94,7 +95,7 @@ class TileExtentController { Duration getTileAnimationDelay(Duration pageTarget) { final extent = extentNotifier.value; - final columnCount = ((viewportSize.width + spacing) / (extent + spacing)).round(); + final columnCount = ((viewportSize.width - (horizontalPadding * 2) + spacing) / (extent + spacing)).round(); final rowCount = (viewportSize.height + spacing) ~/ (extent + spacing); return pageTarget ~/ (columnCount + rowCount) * timeDilation; } diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index 1aa035e91..fb6180883 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -70,7 +70,7 @@ class _DebugAndroidAppSectionState extends State with Au ), TextSpan( text: ' ${package.packageName}\n', - style: InfoRowGroup.keyStyle, + style: InfoRowGroup.keyStyle(context), ), WidgetSpan( alignment: PlaceholderAlignment.middle, @@ -94,7 +94,7 @@ class _DebugAndroidAppSectionState extends State with Au ), TextSpan( text: ' ${package.potentialDirs.join(', ')}\n', - style: InfoRowGroup.baseStyle, + style: InfoRowGroup.valueStyle, ), ], ), diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index e14416b85..799e2200e 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -3,8 +3,8 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -65,7 +65,11 @@ class _AddShortcutDialogState extends State { Container( alignment: Alignment.center, padding: const EdgeInsets.only(top: 16), - child: _buildCover(_coverEntry!, extent), + child: ItemPicker( + extent: extent, + entry: _coverEntry!, + onTap: _pickEntry, + ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24), @@ -102,23 +106,6 @@ class _AddShortcutDialogState extends State { ); } - Widget _buildCover(AvesEntry entry, double extent) { - return GestureDetector( - onTap: _pickEntry, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(32)), - child: SizedBox( - width: extent, - height: extent, - child: ThumbnailImage( - entry: entry, - extent: extent, - ), - ), - ), - ); - } - Future _pickEntry() async { final _collection = widget.collection; if (_collection == null) return; @@ -147,5 +134,9 @@ class _AddShortcutDialogState extends State { _isValidNotifier.value = name.isNotEmpty; } - void _submit(BuildContext context) => Navigator.pop(context, Tuple2(_coverEntry, _nameController.text)); + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.pop(context, Tuple2(_coverEntry, _nameController.text)); + } + } } diff --git a/lib/widgets/dialogs/aves_confirmation_dialog.dart b/lib/widgets/dialogs/aves_confirmation_dialog.dart index de287290a..36f77c408 100644 --- a/lib/widgets/dialogs/aves_confirmation_dialog.dart +++ b/lib/widgets/dialogs/aves_confirmation_dialog.dart @@ -5,56 +5,107 @@ import 'package:flutter/material.dart'; import 'aves_dialog.dart'; +abstract class ConfirmationDialogDelegate { + List build(BuildContext context); + + void apply() {} +} + +class MessageConfirmationDialogDelegate extends ConfirmationDialogDelegate { + final String message; + + MessageConfirmationDialogDelegate(this.message); + + @override + List build(BuildContext context) => [ + Padding( + padding: const EdgeInsets.all(16) + const EdgeInsets.only(top: 8), + child: Text(message), + ), + ]; +} + Future showConfirmationDialog({ required BuildContext context, required ConfirmationDialog type, - required String message, + String? message, + ConfirmationDialogDelegate? delegate, required String confirmationButtonLabel, }) async { - if (!settings.confirmationDialogs.contains(type)) return true; + if (!_shouldConfirm(type)) return true; + assert((message != null) ^ (delegate != null)); + final effectiveDelegate = delegate ?? MessageConfirmationDialogDelegate(message!); final confirmed = await showDialog( context: context, - builder: (context) => AvesConfirmationDialog( + builder: (context) => _AvesConfirmationDialog( type: type, - message: message, + delegate: effectiveDelegate, confirmationButtonLabel: confirmationButtonLabel, ), ); - return confirmed == true; + if (confirmed == null) return false; + + if (confirmed) { + effectiveDelegate.apply(); + } + return confirmed; +} + +bool _shouldConfirm(ConfirmationDialog type) { + switch (type) { + case ConfirmationDialog.deleteForever: + return settings.confirmDeleteForever; + case ConfirmationDialog.moveToBin: + return settings.confirmMoveToBin; + case ConfirmationDialog.moveUndatedItems: + return settings.confirmMoveUndatedItems; + } } -class AvesConfirmationDialog extends StatefulWidget { +void _skipConfirmation(ConfirmationDialog type) { + switch (type) { + case ConfirmationDialog.deleteForever: + settings.confirmDeleteForever = false; + break; + case ConfirmationDialog.moveToBin: + settings.confirmMoveToBin = false; + break; + case ConfirmationDialog.moveUndatedItems: + settings.confirmMoveUndatedItems = false; + break; + } +} + +class _AvesConfirmationDialog extends StatefulWidget { final ConfirmationDialog type; - final String message, confirmationButtonLabel; + final ConfirmationDialogDelegate delegate; + final String confirmationButtonLabel; - const AvesConfirmationDialog({ + const _AvesConfirmationDialog({ Key? key, required this.type, - required this.message, + required this.delegate, required this.confirmationButtonLabel, }) : super(key: key); @override - State createState() => _AvesConfirmationDialogState(); + State<_AvesConfirmationDialog> createState() => _AvesConfirmationDialogState(); } -class _AvesConfirmationDialogState extends State { - final ValueNotifier _skipConfirmation = ValueNotifier(false); +class _AvesConfirmationDialogState extends State<_AvesConfirmationDialog> { + final ValueNotifier _skip = ValueNotifier(false); @override Widget build(BuildContext context) { return AvesDialog( scrollableContent: [ - Padding( - padding: const EdgeInsets.all(16) + const EdgeInsets.only(top: 8), - child: Text(widget.message), - ), + ...widget.delegate.build(context), ValueListenableBuilder( - valueListenable: _skipConfirmation, - builder: (context, ask, child) => SwitchListTile( - value: ask, - onChanged: (v) => _skipConfirmation.value = v, + valueListenable: _skip, + builder: (context, flag, child) => SwitchListTile( + value: flag, + onChanged: (v) => _skip.value = v, title: Text(context.l10n.doNotAskAgain), ), ), @@ -66,8 +117,8 @@ class _AvesConfirmationDialogState extends State { ), TextButton( onPressed: () { - if (_skipConfirmation.value) { - settings.confirmationDialogs = settings.confirmationDialogs.toList()..remove(widget.type); + if (_skip.value) { + _skipConfirmation(widget.type); } Navigator.pop(context, true); }, diff --git a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index 28112801c..3b4c70f47 100644 --- a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -2,22 +2,28 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/fields.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/basic/wheel.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; + final CollectionLens? collection; const EditEntryDateDialog({ Key? key, required this.entry, + this.collection, }) : super(key: key); @override @@ -27,21 +33,20 @@ class EditEntryDateDialog extends StatefulWidget { class _EditEntryDateDialogState extends State { DateEditAction _action = DateEditAction.setCustom; DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate; + late AvesEntry _copyItemSource; late DateTime _setDateTime; late ValueNotifier _shiftHour, _shiftMinute; late ValueNotifier _shiftSign; bool _showOptions = false; final Set _fields = {...DateModifier.writableDateFields}; - // use a different shade to avoid having the same background - // on the dialog (using the theme `dialogBackgroundColor`) - // and on the dropdown (using the theme `canvasColor`) - static final dropdownColor = Colors.grey.shade800; + DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now(); @override void initState() { super.initState(); _initSet(); + _initCopyItem(); _initShift(60); } @@ -49,6 +54,10 @@ class _EditEntryDateDialogState extends State { _setDateTime = widget.entry.bestDate ?? DateTime.now(); } + void _initCopyItem() { + _copyItemSource = widget.entry; + } + void _initShift(int initialMinutes) { final abs = initialMinutes.abs(); _shiftHour = ValueNotifier(abs ~/ 60); @@ -81,7 +90,7 @@ class _EditEntryDateDialogState extends State { value: _action, onChanged: (v) => setState(() => _action = v!), isExpanded: true, - dropdownColor: dropdownColor, + dropdownColor: Themes.thirdLayerColor(context), ), ), AnimatedSwitcher( @@ -95,6 +104,7 @@ class _EditEntryDateDialogState extends State { children: [ if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), + if (_action == DateEditAction.copyItem) _buildCopyItemContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context), (_action == DateEditAction.shift || _action == DateEditAction.remove) ? _buildDestinationFields(context) : const SizedBox(height: 8), ], @@ -169,7 +179,28 @@ class _EditEntryDateDialogState extends State { value: _copyFieldSource, onChanged: (v) => setState(() => _copyFieldSource = v!), isExpanded: true, - dropdownColor: dropdownColor, + dropdownColor: Themes.thirdLayerColor(context), + ), + ); + } + + Widget _buildCopyItemContent(BuildContext context) { + final l10n = context.l10n; + final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 8), + child: Row( + children: [ + Expanded(child: Text(formatDateTime(copyItemDate, locale, use24hour))), + const SizedBox(width: 8), + ItemPicker( + extent: 48, + entry: _copyItemSource, + onTap: _pickCopyItemSource, + ), + ], ), ); } @@ -272,6 +303,8 @@ class _EditEntryDateDialogState extends State { return l10n.editEntryDateDialogSetCustom; case DateEditAction.copyField: return l10n.editEntryDateDialogCopyField; + case DateEditAction.copyItem: + return l10n.editEntryDateDialogCopyItem; case DateEditAction.extractFromTitle: return l10n.editEntryDateDialogExtractFromTitle; case DateEditAction.shift: @@ -339,6 +372,27 @@ class _EditEntryDateDialogState extends State { )); } + Future _pickCopyItemSource() async { + final _collection = widget.collection; + if (_collection == null) return; + + final entry = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: ItemPickDialog.routeName), + builder: (context) => ItemPickDialog( + collection: CollectionLens( + source: _collection.source, + ), + ), + fullscreenDialog: true, + ), + ); + if (entry != null) { + setState(() => _copyItemSource = entry); + } + } + DateModifier _getModifier() { // fields to modify are only set for the `shift` and `remove` actions, // as the effective fields for the other actions will depend on @@ -347,9 +401,11 @@ class _EditEntryDateDialogState extends State { case DateEditAction.setCustom: return DateModifier.setCustom(const {}, _setDateTime); case DateEditAction.copyField: - return DateModifier.copyField(const {}, _copyFieldSource); + return DateModifier.copyField(_copyFieldSource); + case DateEditAction.copyItem: + return DateModifier.setCustom(const {}, copyItemDate); case DateEditAction.extractFromTitle: - return DateModifier.extractFromTitle(const {}); + return DateModifier.extractFromTitle(); case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); return DateModifier.shift(_fields, shiftTotalMinutes); diff --git a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart index c7aeb4e52..ec9c74360 100644 --- a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart @@ -1,7 +1,9 @@ import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; @@ -97,6 +99,22 @@ class _RemoveEntryMetadataDialogState extends State { Widget _toTile(MetadataType type) { final text = type.getText(); + Widget child = Text( + text, + style: TextStyle( + shadows: Theme.of(context).brightness == Brightness.dark ? HighlightTitle.shadows : null, + ), + ); + if (context.select((v) => v.themeColorMode == AvesThemeColorMode.polychrome)) { + final colors = context.watch(); + child = DecoratedBox( + decoration: HighlightDecoration( + color: colors.fromBrandColor(BrandColors.get(text)) ?? colors.fromString(text), + ), + child: child, + ); + } + return SwitchListTile( value: _types.contains(type), onChanged: (selected) { @@ -106,17 +124,7 @@ class _RemoveEntryMetadataDialogState extends State { }, title: Align( alignment: Alignment.centerLeft, - child: DecoratedBox( - decoration: HighlightDecoration( - color: BrandColors.get(text) ?? stringToColor(text), - ), - child: Text( - text, - style: const TextStyle( - shadows: HighlightTitle.shadows, - ), - ), - ), + child: child, ), ); } diff --git a/lib/widgets/dialogs/entry_editors/rename_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart similarity index 82% rename from lib/widgets/dialogs/entry_editors/rename_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index 768d9f11a..b3c5875a2 100644 --- a/lib/widgets/dialogs/entry_editors/rename_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -77,15 +77,21 @@ class _RenameEntryDialogState extends State { String _buildEntryPath(String name) { if (name.isEmpty) return ''; - return pContext.join(entry.directory!, name + entry.extension!); + return pContext.join(entry.directory!, '$name${entry.extension}'); } + String get newName => _nameController.text.trimLeft(); + Future _validate() async { - final newName = _nameController.text; - final path = _buildEntryPath(newName); - final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; - _isValidNotifier.value = newName.isNotEmpty && !exists; + final _newName = newName; + final path = _buildEntryPath(_newName); + final exists = _newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; + _isValidNotifier.value = _newName.isNotEmpty && !exists; } - void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.pop(context, newName); + } + } } diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart new file mode 100644 index 000000000..fd749797b --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart @@ -0,0 +1,220 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/naming_pattern.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/collection/collection_grid.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/thumbnail/decorated.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class RenameEntrySetPage extends StatefulWidget { + static const routeName = '/rename_entry_set'; + + final List entries; + + const RenameEntrySetPage({ + Key? key, + required this.entries, + }) : super(key: key); + + @override + State createState() => _RenameEntrySetPageState(); +} + +class _RenameEntrySetPageState extends State { + final TextEditingController _patternTextController = TextEditingController(); + final ValueNotifier _namingPatternNotifier = ValueNotifier(const NamingPattern([])); + + static const int previewMax = 10; + static const double thumbnailExtent = 48; + + List get entries => widget.entries; + + int get entryCount => entries.length; + + @override + void initState() { + super.initState(); + _patternTextController.text = settings.entryRenamingPattern; + _patternTextController.addListener(_onUserPatternChange); + _onUserPatternChange(); + } + + @override + void dispose() { + _patternTextController.removeListener(_onUserPatternChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(l10n.renameEntrySetPageTitle), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _patternTextController, + decoration: InputDecoration( + labelText: l10n.renameEntrySetPagePatternFieldLabel, + ), + autofocus: true, + ), + ), + MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: DateNamingProcessor.key, + child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)), + ), + PopupMenuItem( + value: NameNamingProcessor.key, + child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)), + ), + PopupMenuItem( + value: CounterNamingProcessor.key, + child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)), + ), + ]; + }, + onSelected: (key) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + _insertProcessor(key); + }, + tooltip: l10n.renameEntrySetPageInsertTooltip, + icon: const Icon(AIcons.add), + ), + ), + ], + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l10n.renameEntrySetPagePreview, + style: Constants.titleTextStyle, + ), + ), + Expanded( + child: Selector( + selector: (context, mq) => mq.textScaleFactor, + builder: (context, textScaleFactor, child) { + final effectiveThumbnailExtent = max(thumbnailExtent, thumbnailExtent * textScaleFactor); + return GridTheme( + extent: effectiveThumbnailExtent, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 12), + itemBuilder: (context, index) { + final entry = entries[index]; + final sourceName = entry.filenameWithoutExtension ?? ''; + return Row( + children: [ + DecoratedThumbnail( + entry: entry, + tileExtent: effectiveThumbnailExtent, + selectable: false, + highlightable: false, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sourceName, + style: TextStyle(color: Theme.of(context).textTheme.caption!.color), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + const SizedBox(height: 4), + ValueListenableBuilder( + valueListenable: _namingPatternNotifier, + builder: (context, pattern, child) { + return Text( + pattern.apply(entry, index), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, + ), + ], + ), + ), + ], + ); + }, + separatorBuilder: (context, index) => const SizedBox( + height: CollectionGrid.spacing, + ), + itemCount: min(entryCount, previewMax), + ), + ); + }), + ), + const Divider(height: 0), + Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.entryActionRename, + onPressed: () { + settings.entryRenamingPattern = _patternTextController.text; + Navigator.pop(context, _namingPatternNotifier.value); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _onUserPatternChange() { + _namingPatternNotifier.value = NamingPattern.from( + userPattern: _patternTextController.text, + entryCount: entryCount, + ); + } + + void _insertProcessor(String key) { + final userPattern = _patternTextController.text; + final selection = _patternTextController.selection; + _patternTextController.value = _patternTextController.value.replaced( + TextRange( + start: NamingPattern.getInsertionOffset(userPattern, selection.start), + end: NamingPattern.getInsertionOffset(userPattern, selection.end), + ), + NamingPattern.defaultPatternFor(key), + ); + } +} diff --git a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index 2b861a791..e81574f0a 100644 --- a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -2,11 +2,11 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -72,11 +72,12 @@ class _CoverSelectionDialogState extends State { children: [ title, const Spacer(), - IconButton( - icon: const Icon(AIcons.setCover), - onPressed: _isCustom ? _pickEntry : null, - tooltip: context.l10n.changeTooltip, - ), + if (_customEntry != null) + ItemPicker( + extent: 46, + entry: _customEntry!, + onTap: _pickEntry, + ), ], ) : title, diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart index e32a949c0..ba5ffc5f1 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -157,5 +157,9 @@ class _CreateAlbumDialogState extends State { _isValidNotifier.value = newName.isNotEmpty && !exists; } - void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text)); + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.pop(context, _buildAlbumPath(_nameController.text)); + } + } } diff --git a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart index 288cdaed5..525fb8ff4 100644 --- a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart @@ -84,8 +84,12 @@ class _RenameAlbumDialogState extends State { final path = _buildAlbumPath(newName); final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; _existsNotifier.value = exists && newName != initialValue; - _isValidNotifier.value = newName.isNotEmpty && !exists; + _isValidNotifier.value = newName.isNotEmpty; } - void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.pop(context, _nameController.text); + } + } } diff --git a/lib/widgets/dialogs/item_picker.dart b/lib/widgets/dialogs/item_picker.dart new file mode 100644 index 000000000..b70d7ecc9 --- /dev/null +++ b/lib/widgets/dialogs/item_picker.dart @@ -0,0 +1,72 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; +import 'package:flutter/material.dart'; + +class ItemPicker extends StatelessWidget { + final double extent; + final AvesEntry entry; + final GestureTapCallback? onTap; + + const ItemPicker({ + Key? key, + required this.extent, + required this.entry, + this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final imageBorderRadius = BorderRadius.all(Radius.circular(extent * .25)); + final actionBoxDimension = min(40.0, extent * .4); + final actionBoxBorderRadius = BorderRadiusDirectional.only(topStart: Radius.circular(actionBoxDimension * .6)); + return Tooltip( + message: context.l10n.changeTooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + border: AvesBorder.border(context), + borderRadius: imageBorderRadius, + ), + child: ClipRRect( + borderRadius: imageBorderRadius, + child: SizedBox( + width: extent, + height: extent, + child: Stack( + children: [ + ThumbnailImage( + entry: entry, + extent: extent, + ), + PositionedDirectional( + end: -1, + bottom: -1, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark ? const Color(0xAA000000) : const Color(0xCCFFFFFF), + border: AvesBorder.border(context), + borderRadius: actionBoxBorderRadius, + ), + width: actionBoxDimension, + height: actionBoxDimension, + child: Icon( + AIcons.edit, + size: actionBoxDimension * .6, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/tile_view_dialog.dart b/lib/widgets/dialogs/tile_view_dialog.dart index 61c1a03c1..eb957e3d2 100644 --- a/lib/widgets/dialogs/tile_view_dialog.dart +++ b/lib/widgets/dialogs/tile_view_dialog.dart @@ -78,7 +78,12 @@ class _TileViewDialogState extends State> with final tabs = >[ if (sortOptions.isNotEmpty) Tuple2( - _buildTab(context, const Key('tab-sort'), AIcons.sort, l10n.viewDialogTabSort), + _buildTab( + context, + const Key('tab-sort'), + AIcons.sort, + l10n.viewDialogTabSort, + ), Column( children: sortOptions.entries .map((kv) => _buildRadioListTile( @@ -92,7 +97,13 @@ class _TileViewDialogState extends State> with ), if (groupOptions.isNotEmpty) Tuple2( - _buildTab(context, const Key('tab-group'), AIcons.group, l10n.viewDialogTabGroup, color: canGroup ? null : Theme.of(context).disabledColor), + _buildTab( + context, + const Key('tab-group'), + AIcons.group, + l10n.viewDialogTabGroup, + color: canGroup ? null : Theme.of(context).disabledColor, + ), Column( children: groupOptions.entries .map((kv) => _buildRadioListTile( @@ -106,7 +117,12 @@ class _TileViewDialogState extends State> with ), if (layoutOptions.isNotEmpty) Tuple2( - _buildTab(context, const Key('tab-layout'), AIcons.layout, l10n.viewDialogTabLayout), + _buildTab( + context, + const Key('tab-layout'), + AIcons.layout, + l10n.viewDialogTabLayout, + ), Column( children: layoutOptions.entries .map((kv) => _buildRadioListTile( @@ -209,7 +225,13 @@ class _TileViewDialogState extends State> with ); } - Tab _buildTab(BuildContext context, Key key, IconData icon, String text, {Color? color}) { + Tab _buildTab( + BuildContext context, + Key key, + IconData icon, + String text, { + Color? color, + }) { // cannot use `IconTheme` over `TabBar` to change size, // because `TabBar` does so internally final textScaleFactor = MediaQuery.textScaleFactorOf(context); diff --git a/lib/widgets/dialogs/video_stream_selection_dialog.dart b/lib/widgets/dialogs/video_stream_selection_dialog.dart index e9fa19a0f..106f00ae5 100644 --- a/lib/widgets/dialogs/video_stream_selection_dialog.dart +++ b/lib/widgets/dialogs/video_stream_selection_dialog.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/languages.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:collection/collection.dart'; @@ -167,10 +168,7 @@ class _VideoStreamSelectionDialogState extends State value: current, onChanged: streams.length > 1 ? (newValue) => setState(() => setter(newValue)) : null, isExpanded: true, - // use a different shade to avoid having the same background - // on the dialog (using the theme `dialogBackgroundColor`) - // and on the dropdown (using the theme `canvasColor`) - dropdownColor: Colors.grey.shade800, + dropdownColor: Themes.thirdLayerColor(context), ), ), ]; diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 2d08bb60a..0be0f2e81 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -121,6 +121,7 @@ class AppDrawer extends StatelessWidget { Text( context.l10n.appName, style: const TextStyle( + color: Colors.white, fontSize: 44, fontWeight: FontWeight.w300, letterSpacing: 1.0, @@ -136,6 +137,7 @@ class AppDrawer extends StatelessWidget { style: ButtonStyle( foregroundColor: MaterialStateProperty.all(Colors.white), overlayColor: MaterialStateProperty.all(Colors.white24), + side: MaterialStateProperty.all(BorderSide(width: 1, color: Colors.white.withOpacity(0.12))), ), ), child: Wrap( diff --git a/lib/widgets/drawer/collection_nav_tile.dart b/lib/widgets/drawer/collection_nav_tile.dart index 8d264f276..8a8a73603 100644 --- a/lib/widgets/drawer/collection_nav_tile.dart +++ b/lib/widgets/drawer/collection_nav_tile.dart @@ -1,6 +1,5 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -65,10 +64,8 @@ class CollectionNavTile extends StatelessWidget { MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: CollectionLens( - source: context.read(), - filters: {filter}, - ), + source: context.read(), + filters: {filter}, ), ), (route) => false, diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index fd4c97d8d..fa59ff537 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -77,7 +77,8 @@ class DrawerPageIcon extends StatelessWidget { return const Icon(AIcons.tag); case AppDebugPage.routeName: return ShaderMask( - shaderCallback: AColors.debugGradient.createShader, + shaderCallback: AvesColorsData.debugGradient.createShader, + blendMode: BlendMode.srcIn, child: const Icon(AIcons.debug), ); default: diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index bc49eebac..a0c5b2e6c 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -290,7 +290,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with if (!await checkStoragePermissionForAlbums(context, {album})) return; - if (!(await File(destinationAlbum).exists())) { + if (!await File(destinationAlbum).exists()) { // access to the destination parent is required to create the underlying destination folder if (!await checkStoragePermissionForAlbums(context, {destinationAlbumParent})) return; } diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 8e2769952..30b6ad5e3 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -14,7 +14,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; -import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -151,7 +150,7 @@ class CoveredFilterChip extends StatelessWidget { radius: radius(extent), ), banner: banner, - details: showText ? _buildDetails(source, filter) : null, + details: showText ? _buildDetails(context, source, filter) : null, padding: titlePadding, heroType: heroType, onTap: onTap, @@ -159,7 +158,9 @@ class CoveredFilterChip extends StatelessWidget { ); } - Widget _buildDetails(CollectionSource source, T filter) { + Color _detailColor(BuildContext context) => Theme.of(context).textTheme.caption!.color!; + + Widget _buildDetails(BuildContext context, CollectionSource source, T filter) { final padding = min(8.0, extent / 16); final iconSize = detailIconSize(extent); final fontSize = detailFontSize(extent); @@ -172,7 +173,7 @@ class CoveredFilterChip extends StatelessWidget { duration: Durations.chipDecorationAnimation, child: Icon( AIcons.pin, - color: FilterGridPage.detailColor, + color: _detailColor(context), size: iconSize, ), ), @@ -182,14 +183,14 @@ class CoveredFilterChip extends StatelessWidget { duration: Durations.chipDecorationAnimation, child: Icon( AIcons.removableStorage, - color: FilterGridPage.detailColor, + color: _detailColor(context), size: iconSize, ), ), Text( '${source.count(filter)}', style: TextStyle( - color: FilterGridPage.detailColor, + color: _detailColor(context), fontSize: fontSize, ), ), diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 50679bac9..4710d47f5 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -67,8 +67,6 @@ class FilterGridPage extends StatelessWidget { required this.heroType, }) : super(key: key); - static const Color detailColor = Color(0xFFE0E0E0); - @override Widget build(BuildContext context) { return MediaQueryDataProvider( @@ -166,6 +164,7 @@ class _FilterGridState extends State> extentMin: 60, extentMax: 300, spacing: 8, + horizontalPadding: 2, ); return TileExtentControllerProvider( controller: _tileExtentController!, @@ -239,12 +238,13 @@ class _FilterGridContent extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), builder: (context, thumbnailExtent, child) { - return Selector>( - selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), + return Selector>( + selector: (context, c) => Tuple4(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding), builder: (context, c, child) { final scrollableWidth = c.item1; final columnCount = c.item2; final tileSpacing = c.item3; + final horizontalPadding = c.item4; // do not listen for animation delay change final target = context.read().staggeredAnimationPageTarget; final tileAnimationDelay = context.read().getTileAnimationDelay(target); @@ -267,6 +267,7 @@ class _FilterGridContent extends StatelessWidget { scrollableWidth: scrollableWidth, columnCount: columnCount, spacing: tileSpacing, + horizontalPadding: horizontalPadding, tileWidth: thumbnailExtent, tileHeight: tileHeight, tileBuilder: (gridItem) { @@ -432,8 +433,10 @@ class _FilterScaler extends StatelessWidget { @override Widget build(BuildContext context) { - final tileSpacing = context.select((controller) => controller.spacing); final textScaleFactor = context.select((mq) => mq.textScaleFactor); + final metrics = context.select>((v) => Tuple2(v.spacing, v.horizontalPadding)); + final tileSpacing = metrics.item1; + final horizontalPadding = metrics.item2; return GridScaleGestureDetector>( scrollableKey: scrollableKey, tileLayout: tileLayout, @@ -444,6 +447,7 @@ class _FilterScaler extends StatelessWidget { tileCenter: center, tileSize: tileSize, spacing: tileSpacing, + horizontalPadding: horizontalPadding, borderWidth: AvesFilterChip.outlineWidth, borderRadius: CoveredFilterChip.radius(tileSize.shortestSide), color: Colors.grey.shade700, diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 5a289f21b..9746f1c1a 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -52,7 +52,7 @@ class FilterNavigationPage extends StatelessWidget { emptyBuilder: () => ValueListenableBuilder( valueListenable: source.stateNotifier, builder: (context, sourceState, child) { - return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink(); + return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox(); }, ), // do not always enable hero, otherwise unwanted hero gets triggered diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index b7850d69a..b7d246092 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -2,7 +2,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -95,10 +94,8 @@ class _InteractiveFilterTileState extends State CollectionPage( - collection: CollectionLens( - source: context.read(), - filters: {filter}, - ), + source: context.read(), + filters: {filter}, ), ), ); diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index f638d7847..f7605a8ec 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -37,7 +37,7 @@ class FilterListDetails extends StatelessWidget { return Container( padding: FilterListDetailsTheme.contentPadding, foregroundDecoration: BoxDecoration( - border: Border(top: AvesBorder.straightSide), + border: Border(top: AvesBorder.straightSide(context)), ), margin: FilterListDetailsTheme.contentMargin, child: Column( diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart index 7055bac26..3e12913b5 100644 --- a/lib/widgets/filter_grids/common/section_layout.dart +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -15,6 +15,7 @@ class SectionedFilterListLayoutProvider extends Sect required TileLayout tileLayout, required int columnCount, required double spacing, + required double horizontalPadding, required double tileWidth, required double tileHeight, required Widget Function(FilterGridItem gridItem) tileBuilder, @@ -26,6 +27,7 @@ class SectionedFilterListLayoutProvider extends Sect tileLayout: tileLayout, columnCount: columnCount, spacing: spacing, + horizontalPadding: horizontalPadding, tileWidth: tileWidth, tileHeight: tileHeight, tileBuilder: tileBuilder, diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index f1ccbce77..34fd98f52 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -252,10 +252,8 @@ class _HomePageState extends State { return DirectMaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (_) => CollectionPage( - collection: CollectionLens( - source: source, - filters: filters, - ), + source: source, + filters: filters, ), ); } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 4dfeb4a2f..fb9092f53 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -382,10 +382,8 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) { return CollectionPage( - collection: CollectionLens( - source: openingCollection.source, - filters: openingCollection.filters, - )..addFilter(filter), + source: openingCollection.source, + filters: {...openingCollection.filters, filter}, ); }, ), diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index e426f5799..b002b4f42 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -266,10 +266,8 @@ class CollectionSearchDelegate { MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: CollectionLens( - source: source, - filters: {filter}, - ), + source: source, + filters: {filter}, ), ), (route) => false, diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index fc730e171..801e132a4 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -109,16 +111,19 @@ class _SearchPageState extends State { return Scaffold( appBar: AppBar( leading: widget.delegate.buildLeading(context), - title: TextField( - controller: widget.delegate.queryTextController, - focusNode: _focusNode, - style: theme.textTheme.headline6, - textInputAction: TextInputAction.search, - onSubmitted: (_) => widget.delegate.showResults(context), - decoration: InputDecoration( - border: InputBorder.none, - hintText: context.l10n.searchCollectionFieldHint, - hintStyle: theme.inputDecorationTheme.hintStyle, + title: DefaultTextStyle.merge( + style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]), + child: TextField( + controller: widget.delegate.queryTextController, + focusNode: _focusNode, + style: theme.textTheme.headline6, + textInputAction: TextInputAction.search, + onSubmitted: (_) => widget.delegate.showResults(context), + decoration: InputDecoration( + border: InputBorder.none, + hintText: context.l10n.searchCollectionFieldHint, + hintStyle: theme.inputDecorationTheme.hintStyle, + ), ), ), actions: widget.delegate.buildActions(context), diff --git a/lib/widgets/settings/accessibility/accessibility.dart b/lib/widgets/settings/accessibility/accessibility.dart index e4fd0aadb..5d44b6853 100644 --- a/lib/widgets/settings/accessibility/accessibility.dart +++ b/lib/widgets/settings/accessibility/accessibility.dart @@ -1,11 +1,15 @@ +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/settings/accessibility/remove_animations.dart'; import 'package:aves/widgets/settings/accessibility/time_to_take_action.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class AccessibilitySection extends StatelessWidget { final ValueNotifier expandedNotifier; @@ -20,14 +24,21 @@ class AccessibilitySection extends StatelessWidget { return AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.accessibility, - color: AColors.accessibility, + color: context.select((v) => v.accessibility), ), title: context.l10n.settingsSectionAccessibility, expandedNotifier: expandedNotifier, showHighlight: false, - children: const [ - RemoveAnimationsTile(), - TimeToTakeActionTile(), + children: [ + SettingsSelectionListTile( + values: AccessibilityAnimations.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.accessibilityAnimations, + onSelection: (v) => settings.accessibilityAnimations = v, + tileTitle: context.l10n.settingsRemoveAnimationsTile, + dialogTitle: context.l10n.settingsRemoveAnimationsTitle, + ), + const TimeToTakeActionTile(), ], ); } diff --git a/lib/widgets/settings/accessibility/remove_animations.dart b/lib/widgets/settings/accessibility/remove_animations.dart deleted file mode 100644 index 62ea40095..000000000 --- a/lib/widgets/settings/accessibility/remove_animations.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:aves/model/settings/enums/accessibility_animations.dart'; -import 'package:aves/model/settings/enums/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class RemoveAnimationsTile extends StatelessWidget { - const RemoveAnimationsTile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final currentAnimations = context.select((s) => s.accessibilityAnimations); - - return ListTile( - title: Text(context.l10n.settingsRemoveAnimationsTile), - subtitle: Text(currentAnimations.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentAnimations, - options: Map.fromEntries(AccessibilityAnimations.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsRemoveAnimationsTitle, - ), - onSelection: (v) => settings.accessibilityAnimations = v, - ), - ); - } -} diff --git a/lib/widgets/settings/accessibility/time_to_take_action.dart b/lib/widgets/settings/accessibility/time_to_take_action.dart index d1777b134..79db20731 100644 --- a/lib/widgets/settings/accessibility/time_to_take_action.dart +++ b/lib/widgets/settings/accessibility/time_to_take_action.dart @@ -3,9 +3,8 @@ import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/accessibility_service.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class TimeToTakeActionTile extends StatefulWidget { const TimeToTakeActionTile({Key? key}) : super(key: key); @@ -25,26 +24,20 @@ class _TimeToTakeActionTileState extends State { @override Widget build(BuildContext context) { - final currentTimeToTakeAction = context.select((s) => s.timeToTakeAction); - return FutureBuilder( future: _hasSystemOptionLoader, builder: (context, snapshot) { - if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); + if (snapshot.hasError || !snapshot.hasData) return const SizedBox(); final hasSystemOption = snapshot.data!; final optionValues = hasSystemOption ? AccessibilityTimeout.values : AccessibilityTimeout.values.where((v) => v != AccessibilityTimeout.system).toList(); - return ListTile( - title: Text(context.l10n.settingsTimeToTakeActionTile), - subtitle: Text(currentTimeToTakeAction.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentTimeToTakeAction, - options: Map.fromEntries(optionValues.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsTimeToTakeActionTitle, - ), - onSelection: (v) => settings.timeToTakeAction = v, - ), + + return SettingsSelectionListTile( + values: optionValues, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.timeToTakeAction, + onSelection: (v) => settings.timeToTakeAction = v, + tileTitle: context.l10n.settingsTimeToTakeActionTile, + dialogTitle: context.l10n.settingsTimeToTakeActionTitle, ); }, ); diff --git a/lib/widgets/settings/common/tile_leading.dart b/lib/widgets/settings/common/tile_leading.dart index 4f503f818..45f1140cd 100644 --- a/lib/widgets/settings/common/tile_leading.dart +++ b/lib/widgets/settings/common/tile_leading.dart @@ -1,3 +1,4 @@ +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:decorated_icon/decorated_icon.dart'; @@ -15,7 +16,7 @@ class SettingsTileLeading extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return AnimatedContainer( padding: const EdgeInsets.all(6), decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( @@ -24,9 +25,10 @@ class SettingsTileLeading extends StatelessWidget { )), shape: BoxShape.circle, ), + duration: Durations.themeColorModeAnimation, child: DecoratedIcon( icon, - shadows: Constants.embossShadows, + shadows: Theme.of(context).brightness == Brightness.dark ? Constants.embossShadows : null, size: 18, ), ); diff --git a/lib/widgets/settings/common/tiles.dart b/lib/widgets/settings/common/tiles.dart new file mode 100644 index 000000000..eee450281 --- /dev/null +++ b/lib/widgets/settings/common/tiles.dart @@ -0,0 +1,91 @@ +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SettingsSwitchListTile extends StatelessWidget { + final bool Function(BuildContext, Settings) selector; + final ValueChanged onChanged; + final String title; + final String? subtitle; + final Widget? trailing; + + const SettingsSwitchListTile({ + Key? key, + required this.selector, + required this.onChanged, + required this.title, + this.subtitle, + this.trailing, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Selector( + selector: selector, + builder: (context, current, child) { + Widget titleWidget = Text(title); + if (trailing != null) { + titleWidget = Row( + children: [ + Expanded(child: titleWidget), + AnimatedOpacity( + opacity: current ? 1 : .2, + duration: Durations.toggleableTransitionAnimation, + child: trailing, + ), + ], + ); + } + return SwitchListTile( + value: current, + onChanged: onChanged, + title: titleWidget, + subtitle: subtitle != null ? Text(subtitle!) : null, + ); + }, + ); + } +} + +class SettingsSelectionListTile extends StatelessWidget { + final List values; + final String Function(BuildContext, T) getName; + final T Function(BuildContext, Settings) selector; + final ValueChanged onSelection; + final String tileTitle, dialogTitle; + final TextBuilder? optionSubtitleBuilder; + + const SettingsSelectionListTile({ + Key? key, + required this.values, + required this.getName, + required this.selector, + required this.onSelection, + required this.tileTitle, + required this.dialogTitle, + this.optionSubtitleBuilder, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Selector( + selector: selector, + builder: (context, current, child) => ListTile( + title: Text(tileTitle), + subtitle: Text(getName(context, current)), + onTap: () => showSelectionDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: current, + options: Map.fromEntries(values.map((v) => MapEntry(v, getName(context, v)))), + optionSubtitleBuilder: optionSubtitleBuilder, + title: dialogTitle, + ), + onSelection: onSelection, + ), + ), + ); + } +} diff --git a/lib/widgets/settings/display/display.dart b/lib/widgets/settings/display/display.dart new file mode 100644 index 000000000..76f153a28 --- /dev/null +++ b/lib/widgets/settings/display/display.dart @@ -0,0 +1,57 @@ +import 'package:aves/model/settings/enums/display_refresh_rate_mode.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/enums/theme_brightness.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/colors.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DisplaySection extends StatelessWidget { + final ValueNotifier expandedNotifier; + + const DisplaySection({ + Key? key, + required this.expandedNotifier, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AvesExpansionTile( + leading: SettingsTileLeading( + icon: AIcons.display, + color: context.select((v) => v.display), + ), + title: context.l10n.settingsSectionDisplay, + expandedNotifier: expandedNotifier, + showHighlight: false, + children: [ + SettingsSelectionListTile( + values: AvesThemeBrightness.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.themeBrightness, + onSelection: (v) => settings.themeBrightness = v, + tileTitle: context.l10n.settingsThemeBrightness, + dialogTitle: context.l10n.settingsThemeBrightness, + ), + SettingsSwitchListTile( + selector: (context, s) => s.themeColorMode == AvesThemeColorMode.polychrome, + onChanged: (v) => settings.themeColorMode = v ? AvesThemeColorMode.polychrome : AvesThemeColorMode.monochrome, + title: context.l10n.settingsThemeColorHighlights, + ), + SettingsSelectionListTile( + values: DisplayRefreshRateMode.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.displayRefreshRateMode, + onSelection: (v) => settings.displayRefreshRateMode = v, + tileTitle: context.l10n.settingsDisplayRefreshRateModeTile, + dialogTitle: context.l10n.settingsDisplayRefreshRateModeTitle, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/language/language.dart b/lib/widgets/settings/language/language.dart index adcd05e2d..549766333 100644 --- a/lib/widgets/settings/language/language.dart +++ b/lib/widgets/settings/language/language.dart @@ -7,8 +7,8 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/language/locale.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -24,9 +24,6 @@ class LanguageSection extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final currentCoordinateFormat = context.select((s) => s.coordinateFormat); - final currentUnitSystem = context.select((s) => s.unitSystem); - return AvesExpansionTile( // key is expected by test driver key: const Key('section-language'), @@ -35,39 +32,29 @@ class LanguageSection extends StatelessWidget { value: 'language', leading: SettingsTileLeading( icon: AIcons.language, - color: AColors.language, + color: context.select((v) => v.language), ), title: l10n.settingsSectionLanguage, expandedNotifier: expandedNotifier, showHighlight: false, children: [ const LocaleTile(), - ListTile( - title: Text(l10n.settingsCoordinateFormatTile), - subtitle: Text(currentCoordinateFormat.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentCoordinateFormat, - options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.getName(context)))), - optionSubtitleBuilder: (value) => value.format(l10n, Constants.pointNemo), - title: l10n.settingsCoordinateFormatTitle, - ), - onSelection: (v) => settings.coordinateFormat = v, - ), + SettingsSelectionListTile( + values: CoordinateFormat.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.coordinateFormat, + onSelection: (v) => settings.coordinateFormat = v, + tileTitle: l10n.settingsCoordinateFormatTile, + dialogTitle: l10n.settingsCoordinateFormatTitle, + optionSubtitleBuilder: (value) => value.format(l10n, Constants.pointNemo), ), - ListTile( - title: Text(l10n.settingsUnitSystemTile), - subtitle: Text(currentUnitSystem.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentUnitSystem, - options: Map.fromEntries(UnitSystem.values.map((v) => MapEntry(v, v.getName(context)))), - title: l10n.settingsUnitSystemTitle, - ), - onSelection: (v) => settings.unitSystem = v, - ), + SettingsSelectionListTile( + values: UnitSystem.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.unitSystem, + onSelection: (v) => settings.unitSystem = v, + tileTitle: l10n.settingsUnitSystemTile, + dialogTitle: l10n.settingsUnitSystemTitle, ), ], ); diff --git a/lib/widgets/settings/navigation/confirmation_dialogs.dart b/lib/widgets/settings/navigation/confirmation_dialogs.dart index e46a7864a..e45a7890f 100644 --- a/lib/widgets/settings/navigation/confirmation_dialogs.dart +++ b/lib/widgets/settings/navigation/confirmation_dialogs.dart @@ -1,9 +1,7 @@ -import 'package:aves/model/settings/enums/confirmation_dialogs.dart'; -import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class ConfirmationDialogTile extends StatelessWidget { const ConfirmationDialogTile({Key? key}) : super(key: key); @@ -37,29 +35,23 @@ class ConfirmationDialogPage extends StatelessWidget { title: Text(context.l10n.settingsConfirmationDialogTitle), ), body: SafeArea( - child: Selector>( - selector: (context, s) => s.confirmationDialogs, - builder: (context, current, child) => ListView( - children: [ - ConfirmationDialog.moveToBin, - ConfirmationDialog.delete, - ] - .map((dialog) => SwitchListTile( - value: current.contains(dialog), - onChanged: (v) { - final dialogs = current.toList(); - if (v) { - dialogs.add(dialog); - } else { - dialogs.remove(dialog); - } - settings.confirmationDialogs = dialogs; - }, - title: Text(dialog.getName(context)), - )) - .toList(), + child: ListView(children: [ + SettingsSwitchListTile( + selector: (context, s) => s.confirmMoveUndatedItems, + onChanged: (v) => settings.confirmMoveUndatedItems = v, + title: context.l10n.settingsConfirmationDialogMoveUndatedItems, ), - ), + SettingsSwitchListTile( + selector: (context, s) => s.confirmMoveToBin, + onChanged: (v) => settings.confirmMoveToBin = v, + title: context.l10n.settingsConfirmationDialogMoveToBinItems, + ), + SettingsSwitchListTile( + selector: (context, s) => s.confirmDeleteForever, + onChanged: (v) => settings.confirmDeleteForever = v, + title: context.l10n.settingsConfirmationDialogDeleteItems, + ), + ]), ), ); } diff --git a/lib/widgets/settings/navigation/navigation.dart b/lib/widgets/settings/navigation/navigation.dart index 0737957ca..9602fb286 100644 --- a/lib/widgets/settings/navigation/navigation.dart +++ b/lib/widgets/settings/navigation/navigation.dart @@ -6,8 +6,8 @@ import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/navigation/confirmation_dialogs.dart'; import 'package:aves/widgets/settings/navigation/drawer.dart'; import 'package:flutter/material.dart'; @@ -23,51 +23,37 @@ class NavigationSection extends StatelessWidget { @override Widget build(BuildContext context) { - final currentHomePage = context.select((s) => s.homePage); - final currentKeepScreenOn = context.select((s) => s.keepScreenOn); - final currentMustBackTwiceToExit = context.select((s) => s.mustBackTwiceToExit); - return AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.home, - color: AColors.navigation, + color: context.select((v) => v.navigation), ), title: context.l10n.settingsSectionNavigation, expandedNotifier: expandedNotifier, showHighlight: false, children: [ - ListTile( - title: Text(context.l10n.settingsHome), - subtitle: Text(currentHomePage.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentHomePage, - options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsHome, - ), - onSelection: (v) => settings.homePage = v, - ), + SettingsSelectionListTile( + values: HomePageSetting.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.homePage, + onSelection: (v) => settings.homePage = v, + tileTitle: context.l10n.settingsHome, + dialogTitle: context.l10n.settingsHome, ), const NavigationDrawerTile(), const ConfirmationDialogTile(), - ListTile( - title: Text(context.l10n.settingsKeepScreenOnTile), - subtitle: Text(currentKeepScreenOn.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: currentKeepScreenOn, - options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsKeepScreenOnTitle, - ), - onSelection: (v) => settings.keepScreenOn = v, - ), + SettingsSelectionListTile( + values: KeepScreenOn.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.keepScreenOn, + onSelection: (v) => settings.keepScreenOn = v, + tileTitle: context.l10n.settingsKeepScreenOnTile, + dialogTitle: context.l10n.settingsKeepScreenOnTitle, ), - SwitchListTile( - value: currentMustBackTwiceToExit, + SettingsSwitchListTile( + selector: (context, s) => s.mustBackTwiceToExit, onChanged: (v) => settings.mustBackTwiceToExit = v, - title: Text(context.l10n.settingsDoubleBackExit), + title: context.l10n.settingsDoubleBackExit, ), ], ); diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 327326d67..b37bc1abb 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -6,6 +6,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/privacy/access_grants.dart'; import 'package:aves/widgets/settings/privacy/hidden_items.dart'; import 'package:flutter/material.dart'; @@ -26,56 +27,44 @@ class PrivacySection extends StatelessWidget { return AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.privacy, - color: AColors.privacy, + color: context.select((v) => v.privacy), ), title: context.l10n.settingsSectionPrivacy, expandedNotifier: expandedNotifier, showHighlight: false, children: [ - Selector( + SettingsSwitchListTile( selector: (context, s) => s.isInstalledAppAccessAllowed, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.isInstalledAppAccessAllowed = v, - title: Text(context.l10n.settingsAllowInstalledAppAccess), - subtitle: Text(context.l10n.settingsAllowInstalledAppAccessSubtitle), - ), + onChanged: (v) => settings.isInstalledAppAccessAllowed = v, + title: context.l10n.settingsAllowInstalledAppAccess, + subtitle: context.l10n.settingsAllowInstalledAppAccessSubtitle, ), if (canEnableErrorReporting) - Selector( + SettingsSwitchListTile( selector: (context, s) => s.isErrorReportingAllowed, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.isErrorReportingAllowed = v, - title: Text(context.l10n.settingsAllowErrorReporting), - ), + onChanged: (v) => settings.isErrorReportingAllowed = v, + title: context.l10n.settingsAllowErrorReporting, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.saveSearchHistory, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) { - settings.saveSearchHistory = v; - if (!v) { - settings.searchHistory = []; - } - }, - title: Text(context.l10n.settingsSaveSearchHistory), - ), + onChanged: (v) { + settings.saveSearchHistory = v; + if (!v) { + settings.searchHistory = []; + } + }, + title: context.l10n.settingsSaveSearchHistory, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.enableBin, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) { - settings.enableBin = v; - if (!v) { - settings.searchHistory = []; - } - }, - title: Text(context.l10n.settingsEnableBin), - subtitle: Text(context.l10n.settingsEnableBinSubtitle), - ), + onChanged: (v) { + settings.enableBin = v; + if (!v) { + settings.searchHistory = []; + } + }, + title: context.l10n.settingsEnableBin, + subtitle: context.l10n.settingsEnableBinSubtitle, ), const HiddenItemsTile(), if (device.canGrantDirectoryAccess) const StorageAccessTile(), diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 0e217194d..405d9ebe7 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/actions/settings_actions.dart'; @@ -8,12 +9,15 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/accessibility/accessibility.dart'; import 'package:aves/widgets/settings/app_export/items.dart'; import 'package:aves/widgets/settings/app_export/selection_dialog.dart'; +import 'package:aves/widgets/settings/display/display.dart'; import 'package:aves/widgets/settings/language/language.dart'; import 'package:aves/widgets/settings/navigation/navigation.dart'; import 'package:aves/widgets/settings/privacy/privacy.dart'; @@ -71,37 +75,45 @@ class _SettingsPageState extends State with FeedbackMixin { ), ], ), - body: Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyText2: const TextStyle(fontSize: 12), - ), - ), + body: GestureAreaProtectorStack( child: SafeArea( - child: AnimationLimiter( - child: ListView( - padding: const EdgeInsets.all(8), - children: AnimationConfiguration.toStaggeredList( - duration: durations.staggeredAnimation, - delay: durations.staggeredAnimationDelay * timeDilation, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), - ), - children: [ - NavigationSection(expandedNotifier: _expandedNotifier), - ThumbnailsSection(expandedNotifier: _expandedNotifier), - ViewerSection(expandedNotifier: _expandedNotifier), - VideoSection(expandedNotifier: _expandedNotifier), - PrivacySection(expandedNotifier: _expandedNotifier), - AccessibilitySection(expandedNotifier: _expandedNotifier), - LanguageSection(expandedNotifier: _expandedNotifier), - ], + bottom: false, + child: Theme( + data: theme.copyWith( + textTheme: theme.textTheme.copyWith( + // dense style font for tile subtitles, without modifying title font + bodyText2: const TextStyle(fontSize: 12), ), ), + child: AnimationLimiter( + child: Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return ListView( + padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), + children: AnimationConfiguration.toStaggeredList( + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), + ), + children: [ + NavigationSection(expandedNotifier: _expandedNotifier), + ThumbnailsSection(expandedNotifier: _expandedNotifier), + ViewerSection(expandedNotifier: _expandedNotifier), + VideoSection(expandedNotifier: _expandedNotifier), + PrivacySection(expandedNotifier: _expandedNotifier), + AccessibilitySection(expandedNotifier: _expandedNotifier), + DisplaySection(expandedNotifier: _expandedNotifier), + LanguageSection(expandedNotifier: _expandedNotifier), + ], + ), + ); + }), + ), ), ), ), diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index 4e5f00d4c..d12a14d4f 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -1,11 +1,11 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/thumbnails/collection_actions_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -21,131 +21,77 @@ class ThumbnailsSection extends StatelessWidget { @override Widget build(BuildContext context) { final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context); - double opacityFor(bool enabled) => enabled ? 1 : .2; - + final iconColor = context.select((v) => v.neutral); return AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.grid, - color: AColors.thumbnails, + color: context.select((v) => v.thumbnails), ), title: context.l10n.settingsSectionThumbnails, expandedNotifier: expandedNotifier, showHighlight: false, children: [ const CollectionActionsTile(), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailFavourite, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailFavourite = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowFavouriteIcon)), - AnimatedOpacity( - opacity: opacityFor(current), - duration: Durations.toggleableTransitionAnimation, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - FavouriteIcon.scale) / 2), - child: Icon( - AIcons.favourite, - size: iconSize * FavouriteIcon.scale, - ), - ), - ), - ], + onChanged: (v) => settings.showThumbnailFavourite = v, + title: context.l10n.settingsThumbnailShowFavouriteIcon, + trailing: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - FavouriteIcon.scale) / 2), + child: Icon( + AIcons.favourite, + size: iconSize * FavouriteIcon.scale, + color: iconColor, ), ), ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailLocation, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailLocation = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), - AnimatedOpacity( - opacity: opacityFor(current), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.location, - size: iconSize, - ), - ), - ], - ), + onChanged: (v) => settings.showThumbnailLocation = v, + title: context.l10n.settingsThumbnailShowLocationIcon, + trailing: Icon( + AIcons.location, + size: iconSize, + color: iconColor, ), ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailMotionPhoto, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailMotionPhoto = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), - AnimatedOpacity( - opacity: opacityFor(current), - duration: Durations.toggleableTransitionAnimation, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), - child: Icon( - AIcons.motionPhoto, - size: iconSize * MotionPhotoIcon.scale, - ), - ), - ), - ], + onChanged: (v) => settings.showThumbnailMotionPhoto = v, + title: context.l10n.settingsThumbnailShowMotionPhotoIcon, + trailing: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), + child: Icon( + AIcons.motionPhoto, + size: iconSize * MotionPhotoIcon.scale, + color: iconColor, ), ), ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailRating, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailRating = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowRating)), - AnimatedOpacity( - opacity: opacityFor(current), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.rating, - size: iconSize, - ), - ), - ], - ), + onChanged: (v) => settings.showThumbnailRating = v, + title: context.l10n.settingsThumbnailShowRating, + trailing: Icon( + AIcons.rating, + size: iconSize, + color: iconColor, ), ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailRaw, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailRaw = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), - AnimatedOpacity( - opacity: opacityFor(current), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.raw, - size: iconSize, - ), - ), - ], - ), + onChanged: (v) => settings.showThumbnailRaw = v, + title: context.l10n.settingsThumbnailShowRawIcon, + trailing: Icon( + AIcons.raw, + size: iconSize, + color: iconColor, ), ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showThumbnailVideoDuration, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showThumbnailVideoDuration = v, - title: Text(context.l10n.settingsThumbnailShowVideoDuration), - ), + onChanged: (v) => settings.showThumbnailVideoDuration = v, + title: context.l10n.settingsThumbnailShowVideoDuration, ), ], ); diff --git a/lib/widgets/settings/video/controls.dart b/lib/widgets/settings/video/controls.dart index 895148960..1263508bb 100644 --- a/lib/widgets/settings/video/controls.dart +++ b/lib/widgets/settings/video/controls.dart @@ -2,9 +2,8 @@ import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/video_controls.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class VideoControlsTile extends StatelessWidget { const VideoControlsTile({Key? key}) : super(key: key); @@ -40,37 +39,23 @@ class VideoControlsPage extends StatelessWidget { body: SafeArea( child: ListView( children: [ - Selector( + SettingsSelectionListTile( + values: VideoControls.values, + getName: (context, v) => v.getName(context), selector: (context, s) => s.videoControls, - builder: (context, current, child) => ListTile( - title: Text(context.l10n.settingsVideoButtonsTile), - subtitle: Text(current.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: current, - options: Map.fromEntries(VideoControls.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsVideoButtonsTitle, - ), - onSelection: (v) => settings.videoControls = v, - ), - ), + onSelection: (v) => settings.videoControls = v, + tileTitle: context.l10n.settingsVideoButtonsTile, + dialogTitle: context.l10n.settingsVideoButtonsTitle, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.videoGestureDoubleTapTogglePlay, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.videoGestureDoubleTapTogglePlay = v, - title: Text(context.l10n.settingsVideoGestureDoubleTapTogglePlay), - ), + onChanged: (v) => settings.videoGestureDoubleTapTogglePlay = v, + title: context.l10n.settingsVideoGestureDoubleTapTogglePlay, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.videoGestureSideDoubleTapSeek, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v, - title: Text(context.l10n.settingsVideoGestureSideDoubleTapSeek), - ), + onChanged: (v) => settings.videoGestureSideDoubleTapSeek = v, + title: context.l10n.settingsVideoGestureSideDoubleTapSeek, ), ], ), diff --git a/lib/widgets/settings/video/subtitle_sample.dart b/lib/widgets/settings/video/subtitle_sample.dart index 40917395f..208f9eea6 100644 --- a/lib/widgets/settings/video/subtitle_sample.dart +++ b/lib/widgets/settings/video/subtitle_sample.dart @@ -32,7 +32,7 @@ class SubtitleSample extends StatelessWidget { Color(0xffeaecc6), ], ), - border: AvesBorder.border, + border: AvesBorder.border(context), borderRadius: const BorderRadius.all(Radius.circular(24)), ), height: 128, diff --git a/lib/widgets/settings/video/subtitle_theme.dart b/lib/widgets/settings/video/subtitle_theme.dart index 31f2b6ba7..444c65456 100644 --- a/lib/widgets/settings/video/subtitle_theme.dart +++ b/lib/widgets/settings/video/subtitle_theme.dart @@ -2,7 +2,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/basic/color_list_tile.dart'; import 'package:aves/widgets/common/basic/slider_list_tile.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/video/subtitle_sample.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -30,8 +30,6 @@ class SubtitleThemeTile extends StatelessWidget { class SubtitleThemePage extends StatelessWidget { static const routeName = '/settings/video/subtitle_theme'; - static const textAlignOptions = [TextAlign.left, TextAlign.center, TextAlign.right]; - const SubtitleThemePage({Key? key}) : super(key: key); @override @@ -54,18 +52,13 @@ class SubtitleThemePage extends StatelessWidget { Expanded( child: ListView( children: [ - ListTile( - title: Text(context.l10n.settingsSubtitleThemeTextAlignmentTile), - subtitle: Text(_getTextAlignName(context, settings.subtitleTextAlignment)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.subtitleTextAlignment, - options: Map.fromEntries(textAlignOptions.map((v) => MapEntry(v, _getTextAlignName(context, v)))), - title: context.l10n.settingsSubtitleThemeTextAlignmentTitle, - ), - onSelection: (v) => settings.subtitleTextAlignment = v, - ), + SettingsSelectionListTile( + values: const [TextAlign.left, TextAlign.center, TextAlign.right], + getName: _getTextAlignName, + selector: (context, s) => s.subtitleTextAlignment, + onSelection: (v) => settings.subtitleTextAlignment = v, + tileTitle: context.l10n.settingsSubtitleThemeTextAlignmentTile, + dialogTitle: context.l10n.settingsSubtitleThemeTextAlignmentTitle, ), SliderListTile( title: context.l10n.settingsSubtitleThemeTextSize, @@ -95,10 +88,10 @@ class SubtitleThemePage extends StatelessWidget { value: settings.subtitleBackgroundColor.opacity, onChanged: (v) => settings.subtitleBackgroundColor = settings.subtitleBackgroundColor.withOpacity(v), ), - SwitchListTile( - value: settings.subtitleShowOutline, + SettingsSwitchListTile( + selector: (context, s) => s.subtitleShowOutline, onChanged: (v) => settings.subtitleShowOutline = v, - title: Text(context.l10n.settingsSubtitleThemeShowOutline), + title: context.l10n.settingsSubtitleThemeShowOutline, ), ], ), diff --git a/lib/widgets/settings/video/video.dart b/lib/widgets/settings/video/video.dart index 9162582e3..4db331bc1 100644 --- a/lib/widgets/settings/video/video.dart +++ b/lib/widgets/settings/video/video.dart @@ -7,8 +7,8 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/video/controls.dart'; import 'package:aves/widgets/settings/video/subtitle_theme.dart'; import 'package:flutter/material.dart'; @@ -28,45 +28,28 @@ class VideoSection extends StatelessWidget { Widget build(BuildContext context) { final children = [ if (!standalonePage) - Selector( + SettingsSwitchListTile( selector: (context, s) => !s.hiddenFilters.contains(MimeFilter.video), - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.changeFilterVisibility({MimeFilter.video}, v), - title: Text(context.l10n.settingsVideoShowVideos), - ), + onChanged: (v) => settings.changeFilterVisibility({MimeFilter.video}, v), + title: context.l10n.settingsVideoShowVideos, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.enableVideoHardwareAcceleration, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.enableVideoHardwareAcceleration = v, - title: Text(context.l10n.settingsVideoEnableHardwareAcceleration), - ), + onChanged: (v) => settings.enableVideoHardwareAcceleration = v, + title: context.l10n.settingsVideoEnableHardwareAcceleration, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.enableVideoAutoPlay, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.enableVideoAutoPlay = v, - title: Text(context.l10n.settingsVideoEnableAutoPlay), - ), + onChanged: (v) => settings.enableVideoAutoPlay = v, + title: context.l10n.settingsVideoEnableAutoPlay, ), - Selector( + SettingsSelectionListTile( + values: VideoLoopMode.values, + getName: (context, v) => v.getName(context), selector: (context, s) => s.videoLoopMode, - builder: (context, current, child) => ListTile( - title: Text(context.l10n.settingsVideoLoopModeTile), - subtitle: Text(current.getName(context)), - onTap: () => showSelectionDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: current, - options: Map.fromEntries(VideoLoopMode.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsVideoLoopModeTitle, - ), - onSelection: (v) => settings.videoLoopMode = v, - ), - ), + onSelection: (v) => settings.videoLoopMode = v, + tileTitle: context.l10n.settingsVideoLoopModeTile, + dialogTitle: context.l10n.settingsVideoLoopModeTitle, ), const VideoControlsTile(), const SubtitleThemeTile(), @@ -79,7 +62,7 @@ class VideoSection extends StatelessWidget { : AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.video, - color: AColors.video, + color: context.select((v) => v.video), ), title: context.l10n.settingsSectionVideo, expandedNotifier: expandedNotifier, diff --git a/lib/widgets/settings/viewer/entry_background.dart b/lib/widgets/settings/viewer/entry_background.dart index 64fdf6b28..4d808a33d 100644 --- a/lib/widgets/settings/viewer/entry_background.dart +++ b/lib/widgets/settings/viewer/entry_background.dart @@ -49,7 +49,7 @@ class _EntryBackgroundSelectorState extends State { width: radius * 2, decoration: BoxDecoration( color: selected.isColor ? selected.color : null, - border: AvesBorder.border, + border: AvesBorder.border(context), shape: BoxShape.circle, ), child: selected == EntryBackground.checkered diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart index d85832c83..463fa97e9 100644 --- a/lib/widgets/settings/viewer/overlay.dart +++ b/lib/widgets/settings/viewer/overlay.dart @@ -1,5 +1,6 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -38,22 +39,16 @@ class ViewerOverlayPage extends StatelessWidget { body: SafeArea( child: ListView( children: [ - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showOverlayOnOpening, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayOnOpening = v, - title: Text(context.l10n.settingsViewerShowOverlayOnOpening), - ), + onChanged: (v) => settings.showOverlayOnOpening = v, + title: context.l10n.settingsViewerShowOverlayOnOpening, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showOverlayInfo, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayInfo = v, - title: Text(context.l10n.settingsViewerShowInformation), - subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle), - ), + onChanged: (v) => settings.showOverlayInfo = v, + title: context.l10n.settingsViewerShowInformation, + subtitle: context.l10n.settingsViewerShowInformationSubtitle, ), Selector>( selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails), @@ -67,29 +62,20 @@ class ViewerOverlayPage extends StatelessWidget { ); }, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showOverlayMinimap, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayMinimap = v, - title: Text(context.l10n.settingsViewerShowMinimap), - ), + onChanged: (v) => settings.showOverlayMinimap = v, + title: context.l10n.settingsViewerShowMinimap, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.showOverlayThumbnailPreview, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayThumbnailPreview = v, - title: Text(context.l10n.settingsViewerShowOverlayThumbnails), - ), + onChanged: (v) => settings.showOverlayThumbnailPreview = v, + title: context.l10n.settingsViewerShowOverlayThumbnails, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.enableOverlayBlurEffect, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.enableOverlayBlurEffect = v, - title: Text(context.l10n.settingsViewerEnableOverlayBlurEffect), - ), + onChanged: (v) => settings.enableOverlayBlurEffect = v, + title: context.l10n.settingsViewerEnableOverlayBlurEffect, ), ], ), diff --git a/lib/widgets/settings/viewer/viewer.dart b/lib/widgets/settings/viewer/viewer.dart index 794d64bd5..5f993816a 100644 --- a/lib/widgets/settings/viewer/viewer.dart +++ b/lib/widgets/settings/viewer/viewer.dart @@ -6,6 +6,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/viewer/entry_background.dart'; import 'package:aves/widgets/settings/viewer/overlay.dart'; import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart'; @@ -25,7 +26,7 @@ class ViewerSection extends StatelessWidget { return AvesExpansionTile( leading: SettingsTileLeading( icon: AIcons.image, - color: AColors.image, + color: context.select((v) => v.image), ), title: context.l10n.settingsSectionViewer, expandedNotifier: expandedNotifier, @@ -34,21 +35,15 @@ class ViewerSection extends StatelessWidget { const ViewerActionsTile(), const ViewerOverlayTile(), const _CutoutModeSwitch(), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.viewerMaxBrightness, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.viewerMaxBrightness = v, - title: Text(context.l10n.settingsViewerMaximumBrightness), - ), + onChanged: (v) => settings.viewerMaxBrightness = v, + title: context.l10n.settingsViewerMaximumBrightness, ), - Selector( + SettingsSwitchListTile( selector: (context, s) => s.enableMotionPhotoAutoPlay, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.enableMotionPhotoAutoPlay = v, - title: Text(context.l10n.settingsMotionPhotoAutoPlay), - ), + onChanged: (v) => settings.enableMotionPhotoAutoPlay = v, + title: context.l10n.settingsMotionPhotoAutoPlay, ), Selector( selector: (context, s) => s.imageBackground, @@ -87,13 +82,10 @@ class _CutoutModeSwitchState extends State<_CutoutModeSwitch> { future: _canSet, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!) { - return Selector( + return SettingsSwitchListTile( selector: (context, s) => s.viewerUseCutout, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.viewerUseCutout = v, - title: Text(context.l10n.settingsViewerUseCutout), - ), + onChanged: (v) => settings.viewerUseCutout = v, + title: context.l10n.settingsViewerUseCutout, ); } return const SizedBox.shrink(); diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 635b91e05..700b2d0c0 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -1,4 +1,6 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -49,6 +51,8 @@ class FilterTable extends StatelessWidget { builder: (context, constraints) { final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth; final displayedEntries = maxRowCount != null ? sortedEntries.take(maxRowCount!) : sortedEntries; + final theme = Theme.of(context); + final isMonochrome = settings.themeColorMode == AvesThemeColorMode.monochrome; return Table( children: displayedEntries.map((kv) { final filter = filterBuilder(kv.key); @@ -81,14 +85,16 @@ class FilterTable extends StatelessWidget { return LinearPercentIndicator( percent: percent, lineHeight: lineHeight, - backgroundColor: Colors.white24, - progressColor: color, + backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1), + progressColor: isMonochrome ? theme.colorScheme.secondary : color, animation: true, isRTL: isRtl, barRadius: barRadius, center: Text( intl.NumberFormat.percentPattern().format(percent), - style: const TextStyle(shadows: Constants.embossShadows), + style: TextStyle( + shadows: theme.brightness == Brightness.dark ? Constants.embossShadows : null, + ), ), padding: EdgeInsets.zero, ); @@ -98,7 +104,9 @@ class FilterTable extends StatelessWidget { ), Text( '$count', - style: const TextStyle(color: Colors.white70), + style: TextStyle( + color: theme.textTheme.caption!.color, + ), textAlign: TextAlign.end, ), ], diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 32db49dae..24a594ca3 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -10,8 +10,8 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -22,7 +22,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/stats/filter_table.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart' as intl; import 'package:percent_indicator/linear_percent_indicator.dart'; @@ -77,17 +77,26 @@ class StatsPage extends StatelessWidget { text: context.l10n.collectionEmptyImages, ); } else { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; final animate = context.select((v) => v.accessibilityAnimations.animate); final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((k, v) => MapEntry(k, v.length)); final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image'))); final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video'))); - final mimeDonuts = Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate), - _buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate), - ], + final mimeDonuts = Provider.value( + value: isDark ? NeonOnDark() : PastelOnLight(), + child: Builder( + builder: (context) { + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate), + _buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate), + ], + ); + }, + ), ); final catalogued = entries.where((entry) => entry.isCatalogued); @@ -114,14 +123,16 @@ class StatsPage extends StatelessWidget { child: LinearPercentIndicator( percent: withGpsPercent, lineHeight: lineHeight, - backgroundColor: Colors.white24, - progressColor: Theme.of(context).colorScheme.secondary, + backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1), + progressColor: theme.colorScheme.secondary, animation: animate, isRTL: context.isRtl, barRadius: barRadius, center: Text( intl.NumberFormat.percentPattern().format(withGpsPercent), - style: const TextStyle(shadows: Constants.embossShadows), + style: TextStyle( + shadows: isDark ? Constants.embossShadows : null, + ), ), padding: EdgeInsets.zero, ), @@ -176,7 +187,17 @@ class StatsPage extends StatelessWidget { final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); - final seriesData = byMimeTypes.entries.map((kv) => EntryByMimeDatum(mimeType: kv.key, entryCount: kv.value)).toList(); + final colors = context.watch(); + final seriesData = byMimeTypes.entries.map((kv) { + final mimeType = kv.key; + final displayText = MimeUtils.displayType(mimeType); + return EntryByMimeDatum( + mimeType: mimeType, + displayText: displayText, + color: colors.fromString(displayText), + entryCount: kv.value, + ); + }).toList(); seriesData.sort((d1, d2) { final c = d2.entryCount.compareTo(d1.entryCount); return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText); @@ -250,7 +271,9 @@ class StatsPage extends StatelessWidget { const SizedBox(width: 8), Text( '${d.entryCount}', - style: const TextStyle(color: Colors.white70), + style: TextStyle( + color: Theme.of(context).textTheme.caption!.color, + ), ), ], ), @@ -327,10 +350,8 @@ class StatsPage extends StatelessWidget { MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: CollectionLens( - source: source, - filters: {filter}, - ), + source: source, + filters: {filter}, ), ), (route) => false, @@ -338,18 +359,19 @@ class StatsPage extends StatelessWidget { } } -class EntryByMimeDatum { - final String mimeType; - final String displayText; +@immutable +class EntryByMimeDatum extends Equatable { + final String mimeType, displayText; + final Color color; final int entryCount; - EntryByMimeDatum({ + @override + List get props => [mimeType, displayText, color, entryCount]; + + const EntryByMimeDatum({ required this.mimeType, + required this.displayText, + required this.color, required this.entryCount, - }) : displayText = MimeUtils.displayType(mimeType); - - Color get color => stringToColor(displayText); - - @override - String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}'; + }); } diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 8f093ba5b..b2650a91b 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -8,10 +8,8 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; -import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; @@ -27,7 +25,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/rename_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; @@ -39,6 +37,7 @@ import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -190,12 +189,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } final l10n = context.l10n; - if (!(await showConfirmationDialog( + if (!await showConfirmationDialog( context: context, - type: ConfirmationDialog.delete, + type: ConfirmationDialog.deleteForever, message: l10n.deleteEntriesConfirmationDialogMessage(1), confirmationButtonLabel: l10n.deleteButtonLabel, - ))) return; + )) return; if (!await checkStoragePermission(context, {entry})) return; @@ -262,28 +261,19 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final showAction = isMainMode && newUris.isNotEmpty ? SnackBarAction( label: context.l10n.showButtonLabel, - onPressed: () async { - final highlightInfo = context.read(); - final targetCollection = CollectionLens( - source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - ); - unawaited(Navigator.pushAndRemoveUntil( + onPressed: () { + Navigator.pushAndRemoveUntil( context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: targetCollection, + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + highlightTest: (entry) => newUris.contains(entry.uri), ), ), (route) => false, - )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); - final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); - if (targetEntry != null) { - highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); - } + ); }, ) : null; @@ -326,17 +316,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix context: context, builder: (context) => RenameEntryDialog(entry: entry), ); - if (newName == null || newName.isEmpty) return; - - if (!await checkStoragePermission(context, {entry})) return; - - final success = await context.read().renameEntry(entry, newName, persist: _isMainMode(context)); + if (newName == null || newName.isEmpty || newName == entry.filenameWithoutExtension) return; - if (success) { - showFeedback(context, context.l10n.genericSuccessFeedback); - } else { - showFeedback(context, context.l10n.genericFailureFeedback); - } + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + await rename( + context, + entriesToNewName: {entry: '$newName${entry.extension}'}, + persist: _isMainMode(context), + onSuccess: entry.metadataChangeNotifier.notify, + ); } bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 727065f34..1c3f06e4e 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -97,7 +97,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi } Future _editDate(BuildContext context) async { - final modifier = await selectDateModifier(context, {entry}); + final modifier = await selectDateModifier(context, {entry}, collection); if (modifier == null) return; await edit(context, () => entry.editDate(modifier)); diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index ed28f6d7a..59866e2cf 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -39,7 +39,7 @@ class ViewerVerticalPageView extends StatefulWidget { } class _ViewerVerticalPageViewState extends State { - final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); + final ValueNotifier _backgroundOpacityNotifier = ValueNotifier(1); final ValueNotifier _isVerticallyScrollingNotifier = ValueNotifier(false); Timer? _verticalScrollMonitoringTimer; AvesEntry? _oldEntry; @@ -122,12 +122,15 @@ class _ViewerVerticalPageViewState extends State { imagePage, infoPage, ]; - return ValueListenableBuilder( - valueListenable: _backgroundColorNotifier, - builder: (context, backgroundColor, child) => Container( - color: backgroundColor, - child: child, - ), + return ValueListenableBuilder( + valueListenable: _backgroundOpacityNotifier, + builder: (context, backgroundOpacity, child) { + final background = Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white; + return Container( + color: background.withOpacity(backgroundOpacity), + child: child, + ); + }, child: PageView( // key is expected by test driver key: const Key('vertical-pageview'), @@ -196,7 +199,7 @@ class _ViewerVerticalPageViewState extends State { final page = widget.verticalPager.page!; final opacity = min(1.0, page); - _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity); + _backgroundOpacityNotifier.value = opacity * opacity; if (page <= 1 && settings.viewerMaxBrightness) { _systemBrightness?.then((system) { diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 674f55963..16b8f7d31 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -35,7 +35,11 @@ class EntryViewerPage extends StatelessWidget { ), ), ), - backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, + backgroundColor: Navigator.canPop(context) + ? Colors.transparent + : Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, resizeToAvoidBottomInset: false, ), ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 3307af7b7..3773c9e4e 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -414,10 +414,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) { return CollectionPage( - collection: CollectionLens( - source: baseCollection.source, - filters: baseCollection.filters, - )..addFilter(filter), + source: baseCollection.source, + filters: {...baseCollection.filters, filter}, ); }, ), diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 91b3a97a3..6027073fb 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -156,7 +157,7 @@ class BasicSection extends StatelessWidget { DecoratedBox( decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: isEditing ? Theme.of(context).disabledColor : AvesFilterChip.defaultOutlineColor, + color: isEditing ? Theme.of(context).disabledColor : context.select((v) => v.neutral), width: AvesFilterChip.outlineWidth, )), borderRadius: const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index f1c568c5c..f6e72a77d 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -21,7 +21,6 @@ class SectionRow extends StatelessWidget { width: dim, child: Divider( thickness: AvesFilterChip.outlineWidth, - color: Colors.white70, ), ); return Row( @@ -47,11 +46,11 @@ class InfoRowGroup extends StatefulWidget { final Map? linkHandlers; static const keyValuePadding = 16; - static const linkColor = Colors.blue; static const fontSize = 13.0; - static const baseStyle = TextStyle(fontSize: fontSize); - static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 2.0); - static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); + static const valueStyle = TextStyle(fontSize: fontSize); + static final _keyStyle = valueStyle.copyWith(height: 2.0); + + static TextStyle keyStyle(BuildContext context) => Theme.of(context).textTheme.caption!.merge(_keyStyle); const InfoRowGroup({ Key? key, @@ -77,9 +76,11 @@ class _InfoRowGroupState extends State { Widget build(BuildContext context) { if (keyValues.isEmpty) return const SizedBox.shrink(); + final _keyStyle = InfoRowGroup.keyStyle(context); + // compute the size of keys and space in order to align values final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: key, style: InfoRowGroup.keyStyle), textScaleFactor)))); + final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: key, style: _keyStyle), textScaleFactor)))); final lastKey = keyValues.keys.last; return LayoutBuilder( @@ -102,7 +103,10 @@ class _InfoRowGroupState extends State { value = handler.linkText(context); // open link on tap recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); - style = InfoRowGroup.linkStyle; + // `colorScheme.secondary` is overridden upstream as an `ExpansionTileCard` theming workaround, + // so we use `colorScheme.primary` instead + final linkColor = Theme.of(context).colorScheme.primary; + style = InfoRowGroup.valueStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); } else { value = kv.value; // long values are clipped, and made expandable by tapping them @@ -125,14 +129,14 @@ class _InfoRowGroupState extends State { // (e.g. keys on the right for RTL locale, whatever the key intrinsic directionality) // and each span respects the directionality of its inner text only return [ - TextSpan(text: '${Constants.fsi}$key${Constants.pdi}', style: InfoRowGroup.keyStyle), + TextSpan(text: '${Constants.fsi}$key${Constants.pdi}', style: _keyStyle), WidgetSpan(child: SizedBox(width: thisSpaceSize)), TextSpan(text: '${Constants.fsi}$value${Constants.pdi}', style: style, recognizer: recognizer), ]; }, ).toList(), ), - style: InfoRowGroup.baseStyle, + style: InfoRowGroup.valueStyle, ); }, ); diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 79f789b8a..5dc154de2 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -57,30 +57,29 @@ class _InfoPageState extends State { return ValueListenableBuilder( valueListenable: widget.entryNotifier, builder: (context, mainEntry, child) { - if (mainEntry != null) { - Widget _buildContent({AvesEntry? pageEntry}) { - final targetEntry = pageEntry ?? mainEntry; - return EmbeddedDataOpener( - entry: targetEntry, - child: _InfoPageContent( - collection: widget.collection, - entry: targetEntry, - isScrollingNotifier: widget.isScrollingNotifier, - scrollController: _scrollController, - split: mqWidth > splitScreenWidthThreshold, - goToViewer: _goToViewer, - ), - ); - } + if (mainEntry == null) return const SizedBox(); - return mainEntry.isBurst - ? PageEntryBuilder( - multiPageController: context.read().getController(mainEntry), - builder: (pageEntry) => _buildContent(pageEntry: pageEntry), - ) - : _buildContent(); + Widget _buildContent({AvesEntry? pageEntry}) { + final targetEntry = pageEntry ?? mainEntry; + return EmbeddedDataOpener( + entry: targetEntry, + child: _InfoPageContent( + collection: widget.collection, + entry: targetEntry, + isScrollingNotifier: widget.isScrollingNotifier, + scrollController: _scrollController, + split: mqWidth > splitScreenWidthThreshold, + goToViewer: _goToViewer, + ), + ); } - return const SizedBox(); + + return mainEntry.isBurst + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); }, ); }, diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 9caaa55ba..839e1dd67 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; -import 'package:aves/utils/color_utils.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -15,6 +15,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class MetadataDirTile extends StatelessWidget { final AvesEntry entry; @@ -58,9 +59,10 @@ class MetadataDirTile extends StatelessWidget { break; } + final colors = context.watch(); return AvesExpansionTile( title: title, - color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName), + highlightColor: dir.color ?? colors.fromBrandColor(BrandColors.get(dirName)) ?? colors.fromString(dirName), expandedNotifier: expandedDirectoryNotifier, initiallyExpanded: initiallyExpanded, children: [ diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 72201bc3d..bfe18d098 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -7,9 +7,9 @@ import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:collection/collection.dart'; @@ -218,13 +218,14 @@ class _MetadataSectionSliverState extends State { if (knownStreams.isNotEmpty) { final indexDigits = knownStreams.length.toString().length; + final colors = context.read(); for (final stream in knownStreams) { final index = (stream[Keys.index] ?? 0) + 1; final typeText = getTypeText(stream); final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText'; final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); if (formattedStreamTags.isNotEmpty) { - final color = stringToColor(typeText); + final color = colors.fromString(typeText); directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color)); } } diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 4bf210b72..9752f011f 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -1,4 +1,5 @@ import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/xmp_utils.dart'; @@ -11,6 +12,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/microsoft.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart'; @@ -19,6 +21,7 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; @immutable class XmpNamespace extends Equatable { @@ -58,6 +61,8 @@ class XmpNamespace extends Equatable { return XmpMgwRegionsNamespace(rawProps); case XmpMMNamespace.ns: return XmpMMNamespace(rawProps); + case XmpMPNamespace.ns: + return XmpMPNamespace(rawProps); case XmpNoteNamespace.ns: return XmpNoteNamespace(rawProps); case XmpPhotoshopNamespace.ns: @@ -98,7 +103,10 @@ class XmpNamespace extends Equatable { 'Iptc4xmpCore': 'IPTC Core', 'Iptc4xmpExt': 'IPTC Extension', 'lr': 'Lightroom', - 'MicrosoftPhoto': 'Microsoft Photo', + 'mediapro': 'MediaPro', + 'MicrosoftPhoto': 'Microsoft Photo 1.0', + 'MP1': 'Microsoft Photo 1.1', + 'MP': 'Microsoft Photo 1.2', 'mwg-rs': 'Regions', 'nga': 'National Gallery of Art', 'panorama': 'Panorama', @@ -122,7 +130,7 @@ class XmpNamespace extends Equatable { Map get buildProps => rawProps; - List buildNamespaceSection() { + List buildNamespaceSection(BuildContext context) { final props = buildProps.entries .map((kv) { final prop = XmpProp(kv.key, kv.value); @@ -146,10 +154,10 @@ class XmpNamespace extends Equatable { ? [ if (displayTitle.isNotEmpty) Padding( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: 8, bottom: 4), child: HighlightTitle( title: displayTitle, - color: BrandColors.get(displayTitle), + color: context.select((v) => v.fromBrandColor(BrandColors.get(displayTitle))), selectable: true, ), ), diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart b/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart new file mode 100644 index 000000000..78366c98f --- /dev/null +++ b/lib/widgets/viewer/info/metadata/xmp_ns/microsoft.dart @@ -0,0 +1,25 @@ +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; +import 'package:flutter/widgets.dart'; + +class XmpMPNamespace extends XmpNamespace { + static const ns = 'MP'; + + static final regionListPattern = RegExp(ns + r':RegionInfo/MPRI:Regions\[(\d+)\]/(.*)'); + + final regionList = >{}; + + XmpMPNamespace(Map rawProps) : super(ns, rawProps); + + @override + bool extractData(XmpProp prop) => extractIndexedStruct(prop, regionListPattern, regionList); + + @override + List buildFromExtractedData() => [ + if (regionList.isNotEmpty) + XmpStructArrayCard( + title: 'Regions', + structByIndex: regionList, + ), + ]; +} diff --git a/lib/widgets/viewer/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart index e447e45cc..372d9d2a7 100644 --- a/lib/widgets/viewer/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -50,6 +51,7 @@ class _XmpStructArrayCardState extends State { } return Card( + color: Themes.thirdLayerColor(context), margin: XmpStructCard.cardMargin, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -120,6 +122,7 @@ class XmpStructCard extends StatelessWidget { @override Widget build(BuildContext context) { return Card( + color: Themes.thirdLayerColor(context), margin: cardMargin, child: Padding( padding: const EdgeInsets.all(8), diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 74cc82ce2..aa7d2c79d 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -7,6 +7,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class XmpDirTile extends StatefulWidget { final AvesEntry entry; @@ -43,7 +44,7 @@ class _XmpDirTileState extends State { return AvesExpansionTile( // title may contain parent to distinguish multiple XMP directories title: widget.title, - color: AColors.xmp, + highlightColor: context.select((v) => v.xmp), expandedNotifier: widget.expandedNotifier, initiallyExpanded: widget.initiallyExpanded, children: [ @@ -51,7 +52,7 @@ class _XmpDirTileState extends State { padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: sections.expand((section) => section.buildNamespaceSection()).toList(), + children: sections.expand((section) => section.buildNamespaceSection(context)).toList(), ), ), ], diff --git a/lib/widgets/viewer/info/owner.dart b/lib/widgets/viewer/info/owner.dart index fe01690cd..302516c51 100644 --- a/lib/widgets/viewer/info/owner.dart +++ b/lib/widgets/viewer/info/owner.dart @@ -63,7 +63,7 @@ class _OwnerPropState extends State { children: [ TextSpan( text: context.l10n.viewerInfoLabelOwner, - style: InfoRowGroup.keyStyle, + style: InfoRowGroup.keyStyle(context), ), WidgetSpan( alignment: PlaceholderAlignment.middle, @@ -81,7 +81,7 @@ class _OwnerPropState extends State { ), TextSpan( text: appName, - style: InfoRowGroup.baseStyle, + style: InfoRowGroup.valueStyle, ), ], ), diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index 4aa7331d1..0c94ca5a5 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -1,10 +1,9 @@ import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:flutter/material.dart'; -Color overlayBackgroundColor({required bool blurred}) => blurred ? Colors.black26 : Colors.black38; - class OverlayButton extends StatelessWidget { final Animation scale; final BorderRadius? borderRadius; @@ -19,6 +18,7 @@ class OverlayButton extends StatelessWidget { @override Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; final blurred = settings.enableOverlayBlurEffect; return ScaleTransition( scale: scale, @@ -29,10 +29,10 @@ class OverlayButton extends StatelessWidget { child: Material( type: MaterialType.button, borderRadius: borderRadius, - color: overlayBackgroundColor(blurred: blurred), + color: Themes.overlayBackgroundColor(brightness: brightness, blurred: blurred), child: Ink( decoration: BoxDecoration( - border: AvesBorder.border, + border: AvesBorder.border(context), borderRadius: borderRadius, ), child: child, @@ -43,10 +43,10 @@ class OverlayButton extends StatelessWidget { enabled: blurred, child: Material( type: MaterialType.circle, - color: overlayBackgroundColor(blurred: blurred), + color: Themes.overlayBackgroundColor(brightness: brightness, blurred: blurred), child: Ink( decoration: BoxDecoration( - border: AvesBorder.border, + border: AvesBorder.border(context), shape: BoxShape.circle, ), child: child, @@ -78,6 +78,7 @@ class OverlayTextButton extends StatelessWidget { @override Widget build(BuildContext context) { final blurred = settings.enableOverlayBlurEffect; + final theme = Theme.of(context); return SizeTransition( sizeFactor: scale, child: BlurredRRect.all( @@ -86,11 +87,11 @@ class OverlayTextButton extends StatelessWidget { child: OutlinedButton( onPressed: onPressed, style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(overlayBackgroundColor(blurred: blurred)), - foregroundColor: MaterialStateProperty.all(Colors.white), - overlayColor: MaterialStateProperty.all(Colors.white.withOpacity(0.12)), + backgroundColor: MaterialStateProperty.all(Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred)), + foregroundColor: MaterialStateProperty.all(theme.colorScheme.onSurface), + overlayColor: theme.brightness == Brightness.dark ? MaterialStateProperty.all(Colors.white.withOpacity(0.12)) : null, minimumSize: _minSize, - side: MaterialStateProperty.all(AvesBorder.curvedSide), + side: MaterialStateProperty.all(AvesBorder.curvedSide(context)), shape: MaterialStateProperty.all(const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), )), diff --git a/lib/widgets/viewer/overlay/details.dart b/lib/widgets/viewer/overlay/details.dart index e0c8bb44b..9ebff5b71 100644 --- a/lib/widgets/viewer/overlay/details.dart +++ b/lib/widgets/viewer/overlay/details.dart @@ -24,6 +24,8 @@ const double _iconSize = 16.0; const double _interRowPadding = 2.0; const double _subRowMinWidth = 300.0; +List? _shadows(BuildContext context) => Theme.of(context).brightness == Brightness.dark ? Constants.embossShadows : null; + class ViewerDetailOverlay extends StatefulWidget { final List entries; final int index; @@ -136,115 +138,149 @@ class ViewerDetailOverlayContent extends StatelessWidget { @override Widget build(BuildContext context) { final infoMaxWidth = availableWidth - padding.horizontal; - final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController); - final hasShootingDetails = details != null && !details!.isEmpty && settings.showOverlayShootingDetails; - final animationDuration = context.select((v) => v.viewerOverlayChangeAnimation); - - return DefaultTextStyle( - style: Theme.of(context).textTheme.bodyText2!.copyWith( - shadows: Constants.embossShadows, - ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - child: Padding( - padding: padding, - child: Selector( - selector: (context, mq) => mq.orientation, - builder: (context, orientation, child) { - final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; - final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (positionTitle.isNotEmpty) positionTitle, - if (twoColumns) - Padding( - padding: const EdgeInsets.only(top: _interRowPadding), - child: Row( + final showShooting = settings.showOverlayShootingDetails; + + return AnimatedBuilder( + animation: pageEntry.metadataChangeNotifier, + builder: (context, child) { + final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController); + return DefaultTextStyle( + style: Theme.of(context).textTheme.bodyText2!.copyWith( + shadows: _shadows(context), + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + child: Padding( + padding: padding, + child: Selector( + selector: (context, mq) => mq.orientation, + builder: (context, orientation, child) { + final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; + final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; + final collapsedShooting = twoColumns && showShooting; + final collapsedLocation = twoColumns && !showShooting; + + final rows = []; + if (positionTitle.isNotEmpty) { + rows.add(positionTitle); + rows.add(const SizedBox(height: _interRowPadding)); + } + if (twoColumns) { + rows.add( + Row( children: [ - SizedBox( - width: subRowWidth, - child: _DateRow( - entry: pageEntry, - multiPageController: multiPageController, - )), - _buildDuoShootingRow(subRowWidth, hasShootingDetails, animationDuration), + _buildDateSubRow(subRowWidth), + if (collapsedShooting) _buildShootingSubRow(context, subRowWidth), + if (collapsedLocation) _buildLocationSubRow(context, subRowWidth), ], ), - ) - else ...[ - Container( - padding: const EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _DateRow( - entry: pageEntry, - multiPageController: multiPageController, - ), - ), - _buildSoloShootingRow(subRowWidth, hasShootingDetails, animationDuration), - ], - _buildSoloLocationRow(animationDuration), - ], - ); - }, - ), - ), + ); + } else { + rows.add(_buildDateSubRow(subRowWidth)); + if (showShooting) { + rows.add(_buildShootingFullRow(context, subRowWidth)); + } + } + if (!collapsedLocation) { + rows.add(_buildLocationFullRow(context)); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: rows, + ); + }, + ), + ), + ); + }, ); } - Widget _buildSoloLocationRow(Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: _soloTransition, - child: pageEntry.hasGps - ? Container( - padding: const EdgeInsets.only(top: _interRowPadding), - child: _LocationRow(entry: pageEntry), - ) - : const SizedBox(), + Widget _buildDateSubRow(double subRowWidth) => SizedBox( + width: subRowWidth, + child: _DateRow( + entry: pageEntry, + multiPageController: multiPageController, + ), ); - Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: _soloTransition, - child: hasShootingDetails - ? Container( - padding: const EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _ShootingRow(details!), - ) - : const SizedBox(), + Widget _buildShootingFullRow(BuildContext context, double subRowWidth) => _buildFullRowSwitcher( + context: context, + visible: details != null && details!.isNotEmpty, + builder: (context) => SizedBox( + width: subRowWidth, + child: _ShootingRow(details!), + ), + ); + + Widget _buildShootingSubRow(BuildContext context, double subRowWidth) => _buildSubRowSwitcher( + context: context, + subRowWidth: subRowWidth, + visible: details != null && details!.isNotEmpty, + builder: (context) => _ShootingRow(details!), + ); + + Widget _buildLocationFullRow(BuildContext context) => _buildFullRowSwitcher( + context: context, + visible: pageEntry.hasGps, + builder: (context) => _LocationRow(entry: pageEntry), + ); + + Widget _buildLocationSubRow(BuildContext context, double subRowWidth) => _buildSubRowSwitcher( + context: context, + subRowWidth: subRowWidth, + visible: pageEntry.hasGps, + builder: (context) => _LocationRow(entry: pageEntry), ); - Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, + Widget _buildSubRowSwitcher({ + required BuildContext context, + required double subRowWidth, + required bool visible, + required WidgetBuilder builder, + }) => + AnimatedSwitcher( + duration: context.select((v) => v.viewerOverlayChangeAnimation), switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: child, ), - child: hasShootingDetails + child: visible ? SizedBox( width: subRowWidth, - child: _ShootingRow(details!), + child: builder(context), ) : const SizedBox(), ); - static Widget _soloTransition(Widget child, Animation animation) => FadeTransition( - opacity: animation, - child: SizeTransition( - axisAlignment: 1, - sizeFactor: animation, - child: child, + Widget _buildFullRowSwitcher({ + required BuildContext context, + required bool visible, + required WidgetBuilder builder, + }) => + AnimatedSwitcher( + duration: context.select((v) => v.viewerOverlayChangeAnimation), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axisAlignment: 1, + sizeFactor: animation, + child: child, + ), ), + child: visible + ? Padding( + padding: const EdgeInsets.only(top: _interRowPadding), + child: builder(context), + ) + : const SizedBox(), ); } @@ -271,7 +307,7 @@ class _LocationRow extends AnimatedWidget { } return Row( children: [ - const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: _iconSize), + DecoratedIcon(AIcons.location, shadows: _shadows(context), size: _iconSize), const SizedBox(width: _iconPadding), Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), ], @@ -352,7 +388,7 @@ class _DateRow extends StatelessWidget { return Row( children: [ - const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: _iconSize), + DecoratedIcon(AIcons.date, shadows: _shadows(context), size: _iconSize), const SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)), @@ -381,7 +417,7 @@ class _ShootingRow extends StatelessWidget { return Row( children: [ - const DecoratedIcon(AIcons.shooting, shadows: Constants.embossShadows, size: _iconSize), + DecoratedIcon(AIcons.shooting, shadows: _shadows(context), size: _iconSize), const SizedBox(width: _iconPadding), Expanded(child: Text(apertureText, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), diff --git a/lib/widgets/viewer/overlay/thumbnail_preview.dart b/lib/widgets/viewer/overlay/thumbnail_preview.dart index 18f736556..4218fd089 100644 --- a/lib/widgets/viewer/overlay/thumbnail_preview.dart +++ b/lib/widgets/viewer/overlay/thumbnail_preview.dart @@ -62,5 +62,9 @@ class _ViewerThumbnailPreviewState extends State { ); } - void _onScrollerIndexChange() => _debouncer(() => ViewEntryNotification(index: _entryIndexNotifier.value).dispatch(context)); + void _onScrollerIndexChange() => _debouncer(() { + if (mounted) { + ViewEntryNotification(index: _entryIndexNotifier.value).dispatch(context); + } + }); } diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index fdebc1f4b..b7075e7bd 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,9 +1,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; -import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/details.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; @@ -74,7 +74,7 @@ class ViewerTopOverlay extends StatelessWidget { BlurredRect( enabled: blurred, child: Container( - color: overlayBackgroundColor(blurred: blurred), + color: Themes.overlayBackgroundColor(brightness: Theme.of(context).brightness, blurred: blurred), child: SafeArea( minimum: EdgeInsets.only(top: (viewInsets?.top ?? 0) + (viewPadding?.top ?? 0)), bottom: false, diff --git a/lib/widgets/viewer/overlay/video/progress_bar.dart b/lib/widgets/viewer/overlay/video/progress_bar.dart index 6138b37f2..185b7e691 100644 --- a/lib/widgets/viewer/overlay/video/progress_bar.dart +++ b/lib/widgets/viewer/overlay/video/progress_bar.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class VideoProgressBar extends StatefulWidget { final AvesVideoController? controller; @@ -38,7 +40,10 @@ class _VideoProgressBarState extends State { @override Widget build(BuildContext context) { final blurred = settings.enableOverlayBlurEffect; - const textStyle = TextStyle(shadows: Constants.embossShadows); + final brightness = Theme.of(context).brightness; + final textStyle = TextStyle( + shadows: brightness == Brightness.dark ? Constants.embossShadows : null, + ); return SizeTransition( sizeFactor: widget.scale, child: BlurredRRect.all( @@ -66,8 +71,8 @@ class _VideoProgressBarState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), decoration: BoxDecoration( - color: overlayBackgroundColor(blurred: blurred), - border: AvesBorder.border, + color: Themes.overlayBackgroundColor(brightness: brightness, blurred: blurred), + border: AvesBorder.border(context), borderRadius: const BorderRadius.all(Radius.circular(radius)), ), child: Column( @@ -106,15 +111,21 @@ class _VideoProgressBarState extends State { if (!progress.isFinite) progress = 0.0; return LinearProgressIndicator( value: progress, - backgroundColor: Colors.grey.shade700, + backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2), ); }), ), ), - const Text( - // fake text below to match the height of the text above and center the whole thing - '', - style: textStyle, + Row( + children: [ + _buildSpeedIndicator(), + _buildMuteIndicator(), + Text( + // fake text below to match the height of the text above and center the whole thing + '', + style: textStyle, + ), + ], ), ], ), @@ -125,6 +136,38 @@ class _VideoProgressBarState extends State { ); } + Widget _buildSpeedIndicator() => StreamBuilder( + stream: controller?.speedStream ?? Stream.value(1.0), + builder: (context, snapshot) { + final speed = controller?.speed ?? 1.0; + return speed != 1 + ? Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Text('x$speed'), + ) + : const SizedBox(); + }, + ); + + Widget _buildMuteIndicator() => StreamBuilder( + stream: controller?.volumeStream ?? Stream.value(1.0), + builder: (context, snapshot) { + final isMuted = controller?.isMuted ?? false; + return isMuted + ? Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Selector( + selector: (context, mq) => mq.textScaleFactor, + builder: (context, textScaleFactor, child) => Icon( + AIcons.mute, + size: 16 * textScaleFactor, + ), + ), + ) + : const SizedBox(); + }, + ); + void _seekFromTap(Offset globalPosition) async { if (controller == null) return; final keyContext = _progressBarKey.currentContext!; diff --git a/lib/widgets/viewer/overlay/viewer_button_row.dart b/lib/widgets/viewer/overlay/viewer_button_row.dart index 3b8585b77..a13158e12 100644 --- a/lib/widgets/viewer/overlay/viewer_button_row.dart +++ b/lib/widgets/viewer/overlay/viewer_button_row.dart @@ -352,26 +352,37 @@ class ViewerButtonRowContent extends StatelessWidget { ); Widget buildItem(EntryAction action) => Expanded( - child: PopupMenuItem( - value: action, - child: Tooltip( - message: action.getText(context), - child: Center(child: action.getIcon()), + child: Material( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: PopupMenuItem( + value: action, + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), ), ), ); return PopupMenuItem( - child: Row( - children: [ - buildDivider(), - buildItem(EntryAction.rotateCCW), - buildDivider(), - buildItem(EntryAction.rotateCW), - buildDivider(), - buildItem(EntryAction.flip), - buildDivider(), - ], + child: IconTheme.merge( + data: IconThemeData( + color: ListTileTheme.of(context).iconColor, + ), + child: Row( + children: [ + buildDivider(), + buildItem(EntryAction.rotateCCW), + buildDivider(), + buildItem(EntryAction.rotateCW), + buildDivider(), + buildItem(EntryAction.flip), + buildDivider(), + ], + ), ), ); } diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index 6507d5e58..cb07d6e4a 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -94,6 +94,8 @@ abstract class AvesVideoController { Stream get volumeStream; + Stream get speedStream; + bool get isReady; bool get isPlaying => status == VideoStatus.playing; diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 253a946b8..2954be504 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -18,6 +18,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { final StreamController _valueStreamController = StreamController.broadcast(); final StreamController _timedTextStreamController = StreamController.broadcast(); final StreamController _volumeStreamController = StreamController.broadcast(); + final StreamController _speedStreamController = StreamController.broadcast(); final AChangeNotifier _completedNotifier = AChangeNotifier(); Offset _macroBlockCrop = Offset.zero; final List _streams = []; @@ -333,6 +334,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Stream get volumeStream => _volumeStreamController.stream; + @override + Stream get speedStream => _speedStreamController.stream; + @override bool get isReady => _instance.isPlayable(); @@ -372,6 +376,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { if (speed <= 0 || _speed == speed) return; final optionChange = _needSoundTouch(speed) != _needSoundTouch(_speed); _speed = speed; + _speedStreamController.add(_speed); if (optionChange) { _init(startMillis: currentPosition); diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index da920b545..0873f0ef0 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/filters/album.dart'; -import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -113,30 +112,21 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final showAction = _collection != null ? SnackBarAction( label: context.l10n.showButtonLabel, - onPressed: () async { - final highlightInfo = context.read(); + onPressed: () { final source = _collection.source; - final targetCollection = CollectionLens( - source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - ); - unawaited(Navigator.pushAndRemoveUntil( + final newUri = newFields['uri'] as String?; + Navigator.pushAndRemoveUntil( context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - collection: targetCollection, + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + highlightTest: (entry) => entry.uri == newUri, ), ), (route) => false, - )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); - final newUri = newFields['uri'] as String?; - final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => entry.uri == newUri); - if (targetEntry != null) { - highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); - } + ); }, ) : null; diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index f89ff0c79..5d74b5c9a 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -209,6 +209,7 @@ class _EntryPageViewState extends State { _actionFeedbackChildNotifier.value = DecoratedIcon( icon?.call() ?? action.getIconData(), size: 48, + color: Colors.white, shadows: const [ Shadow( color: Colors.black, diff --git a/pubspec.lock b/pubspec.lock index b5cae8fce..3b120d999 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -266,7 +266,7 @@ packages: path: "." ref: HEAD resolved-ref: e0380a550c4be0ef52a70f398c2211fc503094e8 - url: "git://github.com/deckerst/expansion_tile_card.git" + url: "https://github.com/deckerst/expansion_tile_card.git" source: git version: "2.0.0" fake_async: @@ -289,7 +289,7 @@ packages: path: "." ref: aves resolved-ref: "2aa0f5f08135de00966e9b71e58cddb61f93e81c" - url: "git://github.com/deckerst/fijkplayer.git" + url: "https://github.com/deckerst/fijkplayer.git" source: git version: "0.10.0" file: @@ -360,6 +360,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1" + flutter_displaymode: + dependency: "direct main" + description: + name: flutter_displaymode + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.2" flutter_driver: dependency: "direct dev" description: flutter @@ -741,7 +748,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "3.7.1" + version: "3.7.2" percent_indicator: dependency: "direct main" description: @@ -825,7 +832,7 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "5.7.2" + version: "5.7.3" process: dependency: transitive description: @@ -853,7 +860,7 @@ packages: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" qr: dependency: transitive description: @@ -1019,7 +1026,7 @@ packages: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.1" stack_trace: dependency: transitive description: @@ -1047,7 +1054,7 @@ packages: path: "." ref: HEAD resolved-ref: cd5ccd925d0348218aaf156f0b9dc4f8caaec7cc - url: "git://github.com/deckerst/aves_streams_channel.git" + url: "https://github.com/deckerst/aves_streams_channel.git" source: git version: "0.3.0" string_scanner: diff --git a/pubspec.yaml b/pubspec.yaml index 8db73db97..c482b89aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,12 +6,15 @@ repository: https://github.com/deckerst/aves # - github changelog: /CHANGELOG.md # - play changelog: /whatsnew/whatsnew-en-US # - izzy changelog: /fastlane/metadata/android/en-US/changelogs/1XXX.txt -version: 1.6.2+68 +version: 1.6.3+69 publish_to: none environment: sdk: '>=2.16.0 <3.0.0' +# following https://github.blog/2021-09-01-improving-git-protocol-security-github/ +# dependency GitHub repos should be referenced via `https://`, not `git://` + # use `scripts/apply_flavor_{flavor}.sh` to set the right dependencies for the flavor dependencies: flutter: @@ -34,13 +37,14 @@ dependencies: event_bus: expansion_tile_card: git: - url: git://github.com/deckerst/expansion_tile_card.git + url: https://github.com/deckerst/expansion_tile_card.git fijkplayer: git: - url: git://github.com/deckerst/fijkplayer.git + url: https://github.com/deckerst/fijkplayer.git ref: aves flex_color_picker: fluster: + flutter_displaymode: flutter_highlight: flutter_map: flutter_markdown: @@ -68,7 +72,7 @@ dependencies: sqflite: streams_channel: git: - url: git://github.com/deckerst/aves_streams_channel.git + url: https://github.com/deckerst/aves_streams_channel.git transparent_image: tuple: url_launcher: diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 22cbb16b5..71223b179 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -7,12 +7,14 @@ import 'media_store_service.dart'; class FakeMediaFileService extends Fake implements MediaFileService { @override - Stream rename( - Iterable entries, { - required String newName, + Stream rename({ + String? opId, + required Map entriesToNewName, }) { final contentId = FakeMediaStoreService.nextId; - final entry = entries.first; + final kv = entriesToNewName.entries.first; + final entry = kv.key; + final newName = kv.value; return Stream.value(MoveOpEvent( success: true, skipped: false, @@ -21,8 +23,6 @@ class FakeMediaFileService extends Fake implements MediaFileService { 'uri': 'content://media/external/images/media/$contentId', 'contentId': contentId, 'path': '${entry.directory}/$newName', - 'displayName': newName, - 'title': newName.substring(0, newName.length - entry.extension!.length), 'dateModifiedSecs': FakeMediaStoreService.dateSecs, }, )); diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index c3ed0d756..813137f95 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -45,7 +45,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { ); } - static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) { + static MoveOpEvent moveOpEventForMove(AvesEntry entry, String sourceAlbum, String destinationAlbum) { final newContentId = nextId; return MoveOpEvent( success: true, @@ -55,8 +55,22 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { 'uri': 'content://media/external/images/media/$newContentId', 'contentId': newContentId, 'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum), - 'displayName': '${entry.filenameWithoutExtension}${entry.extension}', - 'title': entry.filenameWithoutExtension, + 'dateModifiedSecs': FakeMediaStoreService.dateSecs, + }, + ); + } + + static MoveOpEvent moveOpEventForRename(AvesEntry entry, String newName) { + final newContentId = nextId; + final oldName = entry.filenameWithoutExtension!; + return MoveOpEvent( + success: true, + skipped: false, + uri: entry.uri, + newFields: { + 'uri': 'content://media/external/images/media/$newContentId', + 'contentId': newContentId, + 'path': entry.path!.replaceFirst(oldName, newName), 'dateModifiedSecs': FakeMediaStoreService.dateSecs, }, ); diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 68a3e9229..f0957910e 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -190,7 +190,13 @@ void main() { await image1.toggleFavourite(); const albumFilter = AlbumFilter(testAlbum, 'whatever'); await covers.set(albumFilter, image1.id); - await source.renameEntry(image1, 'image1b.jpg', persist: true); + await source.updateAfterRename( + todoEntries: {image1}, + movedOps: { + FakeMediaStoreService.moveOpEventForRename(image1, 'image1b.jpg'), + }, + persist: true, + ); expect(favourites.count, 1); expect(image1.isFavourite, true); @@ -236,7 +242,7 @@ void main() { moveType: MoveType.move, destinationAlbums: {destinationAlbum}, movedOps: { - FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), }, ); @@ -260,7 +266,7 @@ void main() { moveType: MoveType.move, destinationAlbums: {destinationAlbum}, movedOps: { - FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), }, ); @@ -285,7 +291,7 @@ void main() { moveType: MoveType.move, destinationAlbums: {destinationAlbum}, movedOps: { - FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), }, ); @@ -307,7 +313,7 @@ void main() { await source.renameAlbum(sourceAlbum, destinationAlbum, { image1 }, { - FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), }); albumFilter = const AlbumFilter(destinationAlbum, 'whatever'); diff --git a/test/model/naming_pattern_test.dart b/test/model/naming_pattern_test.dart new file mode 100644 index 000000000..c7991e805 --- /dev/null +++ b/test/model/naming_pattern_test.dart @@ -0,0 +1,47 @@ +import 'package:aves/model/naming_pattern.dart'; +import 'package:test/test.dart'; + +void main() { + test('mixed processors', () { + const entryCount = 42; + expect( + NamingPattern.from( + userPattern: 'pure literal', + entryCount: entryCount, + ).processors, + [ + const LiteralNamingProcessor('pure literal'), + ], + ); + expect( + NamingPattern.from( + userPattern: 'prefixsuffix', + entryCount: entryCount, + ).processors, + [ + const LiteralNamingProcessor('prefix'), + DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'), + const LiteralNamingProcessor('suffix'), + ], + ); + expect( + NamingPattern.from( + userPattern: ' ', + entryCount: entryCount, + ).processors, + [ + DateNamingProcessor('yyyy-MM-ddTHH:mm:ss'), + const LiteralNamingProcessor(' '), + const NameNamingProcessor(), + ], + ); + }); + + test('insertion offset', () { + const userPattern = ' infix '; + expect(NamingPattern.getInsertionOffset(userPattern, -1), 0); + expect(NamingPattern.getInsertionOffset(userPattern, 1234), userPattern.length); + expect(NamingPattern.getInsertionOffset(userPattern, 4), 26); + expect(NamingPattern.getInsertionOffset(userPattern, 30), 30); + }); +} diff --git a/untranslated.json b/untranslated.json index 30b7b78cf..5806d5cb7 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,22 +1,26 @@ { - "es": [ - "videoActionMute", - "videoActionUnmute" - ], - - "id": [ - "videoActionMute", - "videoActionUnmute", - "videoControlsPlay", - "videoControlsPlaySeek", - "videoControlsPlayOutside", - "videoControlsNone", - "settingsViewerShowOverlayThumbnails", - "settingsVideoControlsTile", - "settingsVideoControlsTitle", - "settingsVideoButtonsTile", - "settingsVideoButtonsTitle", - "settingsVideoGestureDoubleTapTogglePlay", - "settingsVideoGestureSideDoubleTapSeek" + "ru": [ + "displayRefreshRatePreferHighest", + "displayRefreshRatePreferLowest", + "themeBrightnessLight", + "themeBrightnessDark", + "themeBrightnessBlack", + "moveUndatedConfirmationDialogMessage", + "moveUndatedConfirmationDialogSetDate", + "renameEntrySetPageTitle", + "renameEntrySetPagePatternFieldLabel", + "renameEntrySetPageInsertTooltip", + "renameEntrySetPagePreview", + "renameProcessorCounter", + "renameProcessorName", + "editEntryDateDialogCopyItem", + "collectionRenameFailureFeedback", + "collectionRenameSuccessFeedback", + "settingsConfirmationDialogMoveUndatedItems", + "settingsSectionDisplay", + "settingsThemeBrightness", + "settingsThemeColorHighlights", + "settingsDisplayRefreshRateModeTile", + "settingsDisplayRefreshRateModeTitle" ] } diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index a8d209fe8..d29d6e662 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,5 @@ Thanks for using Aves! -In v1.6.2: -- revisited viewer: new layout, thumbnail previews, video gestures -- storage related fixes for Android 10 and older -- enjoy the app in Japanese +In v1.6.3: +- enjoy the light theme +- rename items in bulk Full changelog available on GitHub \ No newline at end of file