Merge branch 'develop'
This commit is contained in:
commit
c8ab244ae9
284 changed files with 4681 additions and 4008 deletions
6
.github/workflows/check.yml
vendored
6
.github/workflows/check.yml
vendored
|
@ -14,8 +14,8 @@ jobs:
|
|||
steps:
|
||||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: beta
|
||||
flutter-version: '2.2.0-10.1.pre'
|
||||
channel: stable
|
||||
flutter-version: '2.2.1'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
@ -31,4 +31,4 @@ jobs:
|
|||
run: flutter analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: flutter test
|
||||
run: flutter test --no-sound-null-safety
|
||||
|
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
@ -16,8 +16,8 @@ jobs:
|
|||
|
||||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: beta
|
||||
flutter-version: '2.2.0-10.1.pre'
|
||||
channel: stable
|
||||
flutter-version: '2.2.1'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -38,7 +38,7 @@ jobs:
|
|||
run: flutter analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: flutter test
|
||||
run: flutter test --no-sound-null-safety
|
||||
|
||||
- name: Build signed artifacts.
|
||||
# `KEY_JKS` should contain the result of:
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_2.2.0-10.1.pre.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.2.0-10.1.pre.sksl.json
|
||||
flutter build apk --bundle-sksl-path shaders_2.2.1.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.2.1.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -40,5 +40,7 @@ app.*.symbols
|
|||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Exceptions to above rules.
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.4.2] - 2021-06-10
|
||||
### Added
|
||||
- Collection: snack bar action to show moved/copied/exported entries
|
||||
- Collection / Albums / Countries / Tags: when switching device orientation, keep items in view
|
||||
- Collection: when leaving entry from Viewer, make entry visible in collection
|
||||
- Viewer: fixed layout & minimap for videos with non-square pixels
|
||||
|
||||
### Changed
|
||||
- upgraded Flutter to stable v2.2.1
|
||||
- migrated to unsound null safety
|
||||
- Collection / Viewer: improved performance, memory usage
|
||||
- Collection: thumbnail layout change
|
||||
|
||||
### Removed
|
||||
- no support for Android KitKat (API 19), unsupported by Google Maps package
|
||||
|
||||
### Fixed
|
||||
- fixed opening files shared via content URI with incorrect MIME type
|
||||
- refresh collection when entries modified in Viewer no longer match collection filters
|
||||
|
||||
## [v1.4.1] - 2021-04-29
|
||||
### Added
|
||||
- Motion photo support
|
||||
|
|
|
@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
|
||||
- favorites
|
||||
- statistics
|
||||
- support Android API 19 ~ 30 (KitKat ~ R)
|
||||
- support Android API 20 ~ 30 (Lollipop ~ R)
|
||||
- Android integration (app shortcuts, handle view/pick intents)
|
||||
|
||||
## Known Issues
|
||||
|
|
|
@ -29,6 +29,6 @@ linter:
|
|||
unnecessary_lambdas: true
|
||||
|
||||
# misc
|
||||
prefer_const_constructors: false # too noisy
|
||||
prefer_const_constructors: true # should specify `const` as Dart does not build constants when using const constructors without it
|
||||
prefer_const_constructors_in_immutables: true
|
||||
prefer_const_declarations: true
|
||||
|
|
|
@ -53,8 +53,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
minSdkVersion 20
|
||||
targetSdkVersion 30
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
|
||||
|
@ -100,16 +100,16 @@ flutter {
|
|||
|
||||
repositories {
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url "https://s3.amazonaws.com/repo.commonsware.com" }
|
||||
maven { url 'https://s3.amazonaws.com/repo.commonsware.com' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.5.0-rc01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation "androidx.multidex:multidex:2.0.1"
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
|
||||
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
|
||||
|
@ -40,7 +40,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
|
|
@ -127,16 +127,7 @@ object Metadata {
|
|||
Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes")
|
||||
var previewFile = previewFiles[uri]
|
||||
if (previewFile == null) {
|
||||
previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { output ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val b = ByteArray(previewSize)
|
||||
input.read(b, 0, previewSize)
|
||||
output.write(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
previewFile = createPreviewFile(context, uri)
|
||||
previewFiles[uri] = previewFile
|
||||
}
|
||||
Uri.fromFile(previewFile)
|
||||
|
@ -147,6 +138,19 @@ object Metadata {
|
|||
}
|
||||
}
|
||||
|
||||
fun createPreviewFile(context: Context, uri: Uri): File {
|
||||
return File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { output ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val b = ByteArray(previewSize)
|
||||
input.read(b, 0, previewSize)
|
||||
output.write(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? {
|
||||
val safeUri = getSafeUri(context, uri, mimeType, sizeBytes)
|
||||
return StorageUtils.openInputStream(context, safeUri)
|
||||
|
|
|
@ -4,10 +4,44 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
||||
internal class ContentImageProvider : ImageProvider() {
|
||||
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||
var extractorMimeType: String? = null
|
||||
try {
|
||||
val safeUri = Uri.fromFile(Metadata.createPreviewFile(context, uri))
|
||||
StorageUtils.openInputStream(context, safeUri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
|
||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
|
||||
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||
if (it != MimeTypes.TIFF) {
|
||||
extractorMimeType = it
|
||||
if (extractorMimeType != sourceMimeType) {
|
||||
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
}
|
||||
|
||||
val mimeType = extractorMimeType ?: sourceMimeType
|
||||
if (mimeType == null) {
|
||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||
return
|
||||
|
@ -39,6 +73,8 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
const val PATH = MediaStore.MediaColumns.DATA
|
||||
|
||||
|
|
|
@ -1,21 +1,15 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.32'
|
||||
ext.kotlin_version = '1.5.10'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
|
||||
jcenter {
|
||||
content {
|
||||
includeModule("org.jetbrains.trove4j", "trove4j")
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.3'
|
||||
classpath 'com.android.tools.build:gradle:4.2.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.2'
|
||||
classpath 'com.google.gms:google-services:4.3.8'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.0'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,12 +17,6 @@ allprojects {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
|
||||
jcenter {
|
||||
content {
|
||||
includeModule("org.jetbrains.trove4j", "trove4j")
|
||||
}
|
||||
}
|
||||
}
|
||||
// gradle.projectsEvaluated {
|
||||
// tasks.withType(JavaCompile) {
|
||||
|
@ -40,8 +28,6 @@ allprojects {
|
|||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
|
|
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/geo/topojson.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:country_code/country_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -13,17 +14,17 @@ class CountryTopology {
|
|||
|
||||
CountryTopology._private();
|
||||
|
||||
Topology _topology;
|
||||
Topology? _topology;
|
||||
|
||||
Future<Topology> getTopology() => _topology != null ? SynchronousFuture(_topology) : rootBundle.loadString(topoJsonAsset).then(TopoJson().parse);
|
||||
Future<Topology?> getTopology() => _topology != null ? SynchronousFuture(_topology) : rootBundle.loadString(topoJsonAsset).then(TopoJson().parse);
|
||||
|
||||
// returns the country containing given coordinates
|
||||
Future<CountryCode> countryCode(LatLng position) async {
|
||||
Future<CountryCode?> countryCode(LatLng position) async {
|
||||
return _countryOfNumeric(await numericCode(position));
|
||||
}
|
||||
|
||||
// returns the ISO 3166-1 numeric code of the country containing given coordinates
|
||||
Future<int> numericCode(LatLng position) async {
|
||||
Future<int?> numericCode(LatLng position) async {
|
||||
final topology = await getTopology();
|
||||
if (topology == null) return null;
|
||||
|
||||
|
@ -34,21 +35,25 @@ class CountryTopology {
|
|||
// returns a map of the given positions by country
|
||||
Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async {
|
||||
final numericMap = await numericCodeMap(positions);
|
||||
numericMap.remove(null);
|
||||
final codeMap = numericMap.map((key, value) {
|
||||
final code = _countryOfNumeric(key);
|
||||
return code == null ? null : MapEntry(code, value);
|
||||
});
|
||||
codeMap.remove(null);
|
||||
return codeMap;
|
||||
if (numericMap == null) return {};
|
||||
|
||||
final codeMapEntries = numericMap.entries
|
||||
.map((kv) {
|
||||
final code = _countryOfNumeric(kv.key);
|
||||
return MapEntry(code, kv.value);
|
||||
})
|
||||
.where((kv) => kv.key != null)
|
||||
.cast<MapEntry<CountryCode, Set<LatLng>>>();
|
||||
|
||||
return Map.fromEntries(codeMapEntries);
|
||||
}
|
||||
|
||||
// returns a map of the given positions by the ISO 3166-1 numeric code of the country containing them
|
||||
Future<Map<int, Set<LatLng>>> numericCodeMap(Set<LatLng> positions) async {
|
||||
Future<Map<int, Set<LatLng>>?> numericCodeMap(Set<LatLng> positions) async {
|
||||
final topology = await getTopology();
|
||||
if (topology == null) return null;
|
||||
|
||||
return compute(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
|
||||
return compute<_IsoNumericCodeMapData, Map<int, Set<LatLng>>>(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
|
||||
}
|
||||
|
||||
static Future<Map<int, Set<LatLng>>> _isoNumericCodeMap(_IsoNumericCodeMapData data) async {
|
||||
|
@ -58,19 +63,21 @@ class CountryTopology {
|
|||
final byCode = <int, Set<LatLng>>{};
|
||||
for (final position in data.positions) {
|
||||
final code = _getNumeric(topology, countries, position);
|
||||
byCode[code] = (byCode[code] ?? {})..add(position);
|
||||
if (code != null) {
|
||||
byCode[code] = (byCode[code] ?? {})..add(position);
|
||||
}
|
||||
}
|
||||
return byCode;
|
||||
} catch (error, stack) {
|
||||
// an unhandled error in a spawn isolate would make the app crash
|
||||
debugPrint('failed to get country codes with error=$error\n$stack');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
static int _getNumeric(Topology topology, List<Geometry> mruCountries, LatLng position) {
|
||||
static int? _getNumeric(Topology topology, List<Geometry> mruCountries, LatLng position) {
|
||||
final point = [position.longitude, position.latitude];
|
||||
final hit = mruCountries.firstWhere((country) => country.containsPoint(topology, point), orElse: () => null);
|
||||
final hit = mruCountries.firstWhereOrNull((country) => country.containsPoint(topology, point));
|
||||
if (hit == null) return null;
|
||||
|
||||
// promote hit countries, assuming given positions are likely to come from the same countries
|
||||
|
@ -79,12 +86,12 @@ class CountryTopology {
|
|||
mruCountries.insert(0, hit);
|
||||
}
|
||||
|
||||
final idString = (hit.id as String);
|
||||
final idString = (hit.id as String?);
|
||||
final code = idString == null ? null : int.tryParse(idString);
|
||||
return code;
|
||||
}
|
||||
|
||||
static CountryCode _countryOfNumeric(int numeric) {
|
||||
static CountryCode? _countryOfNumeric(int? numeric) {
|
||||
if (numeric == null) return null;
|
||||
try {
|
||||
return CountryCode.ofNumeric(numeric);
|
||||
|
|
|
@ -23,7 +23,6 @@ String _decimal2sexagesimal(final double degDecimal) {
|
|||
|
||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
List<String> toDMS(LatLng latLng) {
|
||||
if (latLng == null) return [];
|
||||
final lat = latLng.latitude;
|
||||
final lng = latLng.longitude;
|
||||
return [
|
||||
|
|
|
@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
// cf https://github.com/topojson/topojson-specification
|
||||
class TopoJson {
|
||||
Future<Topology> parse(String data) async {
|
||||
return compute(_isoParse, data);
|
||||
Future<Topology?> parse(String data) async {
|
||||
return compute<String, Topology?>(_isoParse, data);
|
||||
}
|
||||
|
||||
static Topology _isoParse(String jsonData) {
|
||||
static Topology? _isoParse(String jsonData) {
|
||||
try {
|
||||
final data = json.decode(jsonData) as Map<String, dynamic>;
|
||||
return Topology.parse(data);
|
||||
|
@ -23,7 +23,7 @@ class TopoJson {
|
|||
|
||||
enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection }
|
||||
|
||||
TopoJsonObjectType _parseTopoJsonObjectType(String data) {
|
||||
TopoJsonObjectType? _parseTopoJsonObjectType(String? data) {
|
||||
switch (data) {
|
||||
case 'Topology':
|
||||
return TopoJsonObjectType.topology;
|
||||
|
@ -46,7 +46,7 @@ TopoJsonObjectType _parseTopoJsonObjectType(String data) {
|
|||
}
|
||||
|
||||
class TopologyJsonObject {
|
||||
final List<num> bbox;
|
||||
final List<num>? bbox;
|
||||
|
||||
TopologyJsonObject.parse(Map<String, dynamic> data) : bbox = data.containsKey('bbox') ? (data['bbox'] as List).cast<num>().toList() : null;
|
||||
}
|
||||
|
@ -54,10 +54,19 @@ class TopologyJsonObject {
|
|||
class Topology extends TopologyJsonObject {
|
||||
final Map<String, Geometry> objects;
|
||||
final List<List<List<num>>> arcs;
|
||||
final Transform transform;
|
||||
final Transform? transform;
|
||||
|
||||
Topology.parse(Map<String, dynamic> data)
|
||||
: objects = (data['objects'] as Map).cast<String, dynamic>().map<String, Geometry>((name, geometry) => MapEntry(name, Geometry.build(geometry))),
|
||||
: objects = Map.fromEntries((data['objects'] as Map)
|
||||
.cast<String, dynamic>()
|
||||
.entries
|
||||
.map((kv) {
|
||||
final name = kv.key;
|
||||
final geometry = Geometry.build(kv.value);
|
||||
return geometry != null ? MapEntry(name, geometry) : null;
|
||||
})
|
||||
.where((kv) => kv != null)
|
||||
.cast<MapEntry<String, Geometry>>()),
|
||||
arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<List>().map((position) => position.cast<num>()).toList()).toList(),
|
||||
transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast<String, dynamic>()) : null,
|
||||
super.parse(data);
|
||||
|
@ -69,8 +78,8 @@ class Topology extends TopologyJsonObject {
|
|||
var x = 0, y = 0;
|
||||
arc = arc.map((quantized) {
|
||||
final absolute = List.of(quantized);
|
||||
absolute[0] = (x += quantized[0]) * transform.scale[0] + transform.translate[0];
|
||||
absolute[1] = (y += quantized[1]) * transform.scale[1] + transform.translate[1];
|
||||
absolute[0] = (x += quantized[0] as int) * transform!.scale[0] + transform!.translate[0];
|
||||
absolute[1] = (y += quantized[1] as int) * transform!.scale[1] + transform!.translate[1];
|
||||
return absolute;
|
||||
}).toList();
|
||||
}
|
||||
|
@ -126,17 +135,18 @@ class Transform {
|
|||
|
||||
abstract class Geometry extends TopologyJsonObject {
|
||||
final dynamic id;
|
||||
final Map<String, dynamic> properties;
|
||||
final Map<String, dynamic>? properties;
|
||||
|
||||
Geometry.parse(Map<String, dynamic> data)
|
||||
: id = data.containsKey('id') ? data['id'] : null,
|
||||
properties = data.containsKey('properties') ? data['properties'] as Map<String, dynamic> : null,
|
||||
properties = data.containsKey('properties') ? data['properties'] as Map<String, dynamic>? : null,
|
||||
super.parse(data);
|
||||
|
||||
static Geometry build(Map<String, dynamic> data) {
|
||||
final type = _parseTopoJsonObjectType(data['type'] as String);
|
||||
static Geometry? build(Map<String, dynamic> data) {
|
||||
final type = _parseTopoJsonObjectType(data['type'] as String?);
|
||||
switch (type) {
|
||||
case TopoJsonObjectType.topology:
|
||||
case null:
|
||||
return null;
|
||||
case TopoJsonObjectType.point:
|
||||
return Point.parse(data);
|
||||
|
@ -153,7 +163,6 @@ abstract class Geometry extends TopologyJsonObject {
|
|||
case TopoJsonObjectType.geometrycollection:
|
||||
return GeometryCollection.parse(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool containsPoint(Topology topology, List<num> point) => false;
|
||||
|
@ -198,11 +207,11 @@ class Polygon extends Geometry {
|
|||
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
|
||||
super.parse(data);
|
||||
|
||||
List<List<List<num>>> _rings;
|
||||
List<List<List<num>>>? _rings;
|
||||
|
||||
List<List<List<num>>> rings(Topology topology) {
|
||||
_rings ??= topology._decodePolygonArcs(arcs);
|
||||
return _rings;
|
||||
return _rings!;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -218,11 +227,11 @@ class MultiPolygon extends Geometry {
|
|||
: arcs = (data['arcs'] as List).cast<List>().map((polygon) => polygon.cast<List>().map((arc) => arc.cast<int>()).toList()).toList(),
|
||||
super.parse(data);
|
||||
|
||||
List<List<List<List<num>>>> _polygons;
|
||||
List<List<List<List<num>>>>? _polygons;
|
||||
|
||||
List<List<List<List<num>>>> polygons(Topology topology) {
|
||||
_polygons ??= topology._decodeMultiPolygonArcs(arcs);
|
||||
return _polygons;
|
||||
return _polygons!;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -235,7 +244,7 @@ class GeometryCollection extends Geometry {
|
|||
final List<Geometry> geometries;
|
||||
|
||||
GeometryCollection.parse(Map<String, dynamic> data)
|
||||
: geometries = (data['geometries'] as List).cast<Map<String, dynamic>>().map(Geometry.build).toList(),
|
||||
: geometries = (data['geometries'] as List).cast<Map<String, dynamic>>().map(Geometry.build).where((geometry) => geometry != null).cast<Geometry>().toList(),
|
||||
super.parse(data);
|
||||
|
||||
@override
|
||||
|
|
|
@ -6,11 +6,10 @@ import 'package:flutter/widgets.dart';
|
|||
|
||||
class AppIconImage extends ImageProvider<AppIconImageKey> {
|
||||
const AppIconImage({
|
||||
@required this.packageName,
|
||||
@required this.size,
|
||||
required this.packageName,
|
||||
required this.size,
|
||||
this.scale = 1.0,
|
||||
}) : assert(packageName != null),
|
||||
assert(scale != null);
|
||||
});
|
||||
|
||||
final String packageName;
|
||||
final double size;
|
||||
|
@ -39,7 +38,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
|||
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
||||
try {
|
||||
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
|
||||
if (bytes == null) {
|
||||
if (bytes.isEmpty) {
|
||||
throw StateError('$packageName app icon loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
|
@ -56,9 +55,9 @@ class AppIconImageKey {
|
|||
final double scale;
|
||||
|
||||
const AppIconImageKey({
|
||||
@required this.packageName,
|
||||
@required this.size,
|
||||
this.scale,
|
||||
required this.packageName,
|
||||
required this.size,
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:flutter/widgets.dart';
|
|||
class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||
final RegionProviderKey key;
|
||||
|
||||
RegionProvider(this.key) : assert(key != null);
|
||||
RegionProvider(this.key);
|
||||
|
||||
@override
|
||||
Future<RegionProviderKey> obtainKey(ImageConfiguration configuration) {
|
||||
|
@ -43,7 +43,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
pageId: pageId,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
if (bytes.isEmpty) {
|
||||
throw StateError('$uri ($mimeType) region loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
|
@ -66,30 +66,24 @@ class RegionProviderKey {
|
|||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
final int pageId, rotationDegrees, sampleSize;
|
||||
final int? pageId;
|
||||
final int rotationDegrees, sampleSize;
|
||||
final bool isFlipped;
|
||||
final Rectangle<int> region;
|
||||
final Size imageSize;
|
||||
final double scale;
|
||||
|
||||
const RegionProviderKey({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
@required this.sampleSize,
|
||||
@required this.region,
|
||||
@required this.imageSize,
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
required this.pageId,
|
||||
required this.rotationDegrees,
|
||||
required this.isFlipped,
|
||||
required this.sampleSize,
|
||||
required this.region,
|
||||
required this.imageSize,
|
||||
this.scale = 1.0,
|
||||
}) : assert(uri != null),
|
||||
assert(mimeType != null),
|
||||
assert(rotationDegrees != null),
|
||||
assert(isFlipped != null),
|
||||
assert(sampleSize != null),
|
||||
assert(region != null),
|
||||
assert(imageSize != null),
|
||||
assert(scale != null);
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:flutter/widgets.dart';
|
|||
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||
final ThumbnailProviderKey key;
|
||||
|
||||
ThumbnailProvider(this.key) : assert(key != null);
|
||||
ThumbnailProvider(this.key);
|
||||
|
||||
@override
|
||||
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
|
||||
|
@ -22,6 +22,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: 1.0,
|
||||
debugLabel: kReleaseMode ? null : [key.uri, key.extent].join('-'),
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
|
||||
},
|
||||
|
@ -43,7 +44,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
extent: key.extent,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
if (bytes.isEmpty) {
|
||||
throw StateError('$uri ($mimeType) loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
|
@ -66,25 +67,21 @@ class ThumbnailProviderKey {
|
|||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
final int pageId, rotationDegrees;
|
||||
final int? pageId;
|
||||
final int rotationDegrees;
|
||||
final bool isFlipped;
|
||||
final int dateModifiedSecs;
|
||||
final double extent;
|
||||
|
||||
const ThumbnailProviderKey({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
@required this.dateModifiedSecs,
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
required this.pageId,
|
||||
required this.rotationDegrees,
|
||||
required this.isFlipped,
|
||||
required this.dateModifiedSecs,
|
||||
this.extent = 0,
|
||||
}) : assert(uri != null),
|
||||
assert(mimeType != null),
|
||||
assert(rotationDegrees != null),
|
||||
assert(isFlipped != null),
|
||||
assert(dateModifiedSecs != null),
|
||||
assert(extent != null);
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
|
|
|
@ -8,20 +8,19 @@ import 'package:pedantic/pedantic.dart';
|
|||
|
||||
class UriImage extends ImageProvider<UriImage> {
|
||||
final String uri, mimeType;
|
||||
final int pageId, rotationDegrees, expectedContentLength;
|
||||
final int? pageId, rotationDegrees, expectedContentLength;
|
||||
final bool isFlipped;
|
||||
final double scale;
|
||||
|
||||
const UriImage({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.pageId,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
required this.pageId,
|
||||
required this.rotationDegrees,
|
||||
required this.isFlipped,
|
||||
this.expectedContentLength,
|
||||
this.scale = 1.0,
|
||||
}) : assert(uri != null),
|
||||
assert(scale != null);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UriImage> obtainKey(ImageConfiguration configuration) {
|
||||
|
@ -60,7 +59,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
));
|
||||
},
|
||||
);
|
||||
if (bytes == null) {
|
||||
if (bytes.isEmpty) {
|
||||
throw StateError('$uri ($mimeType) loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
|
|
|
@ -2,15 +2,13 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class UriPicture extends PictureProvider<UriPicture> {
|
||||
const UriPicture({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
ColorFilter colorFilter,
|
||||
}) : assert(uri != null),
|
||||
super(colorFilter);
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
ColorFilter? colorFilter,
|
||||
}) : super(colorFilter);
|
||||
|
||||
final String uri, mimeType;
|
||||
|
||||
|
@ -20,25 +18,30 @@ class UriPicture extends PictureProvider<UriPicture> {
|
|||
}
|
||||
|
||||
@override
|
||||
PictureStreamCompleter load(UriPicture key, {PictureErrorListener onError}) {
|
||||
PictureStreamCompleter load(UriPicture key, {PictureErrorListener? onError}) {
|
||||
return OneFramePictureStreamCompleter(_loadAsync(key, onError: onError), informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<String>('uri', uri);
|
||||
});
|
||||
}
|
||||
|
||||
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
||||
Future<PictureInfo?> _loadAsync(UriPicture key, {PictureErrorListener? onError}) async {
|
||||
assert(key == this);
|
||||
|
||||
final data = await imageFileService.getSvg(uri, mimeType);
|
||||
if (data == null || data.isEmpty) {
|
||||
if (data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decoder = SvgPicture.svgByteDecoder;
|
||||
if (onError != null) {
|
||||
final future = decoder(data, colorFilter, key.toString());
|
||||
unawaited(future.catchError(onError));
|
||||
return future;
|
||||
return decoder(
|
||||
data,
|
||||
colorFilter,
|
||||
key.toString(),
|
||||
).catchError((error, stack) async {
|
||||
onError(error, stack);
|
||||
return Future<PictureInfo>.error(error, stack);
|
||||
});
|
||||
}
|
||||
return decoder(data, colorFilter, key.toString());
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
"@applyButtonLabel": {},
|
||||
"deleteButtonLabel": "DELETE",
|
||||
"@deleteButtonLabel": {},
|
||||
"showButtonLabel": "SHOW",
|
||||
"@showButtonLabel": {},
|
||||
"hideButtonLabel": "HIDE",
|
||||
"@hideButtonLabel": {},
|
||||
"continueButtonLabel": "CONTINUE",
|
||||
|
@ -555,9 +557,9 @@
|
|||
"@settingsVideoEnableHardwareAcceleration": {},
|
||||
"settingsVideoEnableAutoPlay": "Auto play",
|
||||
"@settingsVideoEnableAutoPlay": {},
|
||||
"settingsVideoLoopModeTile": "Loop mode",
|
||||
"settingsVideoLoopModeTile": "Loop mode",
|
||||
"@settingsVideoLoopModeTile": {},
|
||||
"settingsVideoLoopModeTitle": "Loop Mode",
|
||||
"settingsVideoLoopModeTitle": "Loop Mode",
|
||||
"@settingsVideoLoopModeTitle": {},
|
||||
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"applyButtonLabel": "확인",
|
||||
"deleteButtonLabel": "삭제",
|
||||
"showButtonLabel": "보기",
|
||||
"hideButtonLabel": "숨기기",
|
||||
"continueButtonLabel": "다음",
|
||||
"clearTooltip": "초기화",
|
||||
|
@ -259,8 +260,8 @@
|
|||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||
"settingsVideoEnableHardwareAcceleration": "하드웨어 가속",
|
||||
"settingsVideoEnableAutoPlay": "자동 재생",
|
||||
"settingsVideoLoopModeTile": "반복 모드",
|
||||
"settingsVideoLoopModeTitle": "반복 모드",
|
||||
"settingsVideoLoopModeTile": "반복 모드",
|
||||
"settingsVideoLoopModeTitle": "반복 모드",
|
||||
|
||||
"settingsSectionPrivacy": "개인정보 보호",
|
||||
"settingsEnableAnalytics": "진단 데이터 보내기",
|
||||
|
|
182
lib/main.dart
182
lib/main.dart
|
@ -1,36 +1,26 @@
|
|||
// @dart=2.9
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/media_store_source.dart';
|
||||
import 'package:aves/services/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/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_analytics/observer.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:overlay_support/overlay_support.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||
// debugPrintGestureArenaDiagnostics = true;
|
||||
|
||||
// Invert oversized images (debug mode only)
|
||||
// cf https://flutter.dev/docs/development/tools/devtools/inspector
|
||||
// but unaware of device pixel ratio as of Flutter 2.2.1: https://github.com/flutter/flutter/issues/76208
|
||||
//
|
||||
// MaterialApp.checkerboardOffscreenLayers
|
||||
// cf https://flutter.dev/docs/perf/rendering/ui-performance#checking-for-offscreen-layers
|
||||
//
|
||||
// MaterialApp.checkerboardRasterCacheImages
|
||||
// cf https://flutter.dev/docs/perf/rendering/ui-performance#checking-for-non-cached-images
|
||||
//
|
||||
// flutter run --profile --trace-skia
|
||||
|
||||
Isolate.current.addErrorListener(RawReceivePort((pair) async {
|
||||
final List<dynamic> errorAndStacktrace = pair;
|
||||
await FirebaseCrashlytics.instance.recordError(
|
||||
|
@ -41,147 +31,3 @@ void main() {
|
|||
|
||||
runApp(AvesApp());
|
||||
}
|
||||
|
||||
class AvesApp extends StatefulWidget {
|
||||
@override
|
||||
_AvesAppState createState() => _AvesAppState();
|
||||
}
|
||||
|
||||
class _AvesAppState extends State<AvesApp> {
|
||||
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
|
||||
Future<void> _appSetup;
|
||||
final _mediaStoreSource = MediaStoreSource();
|
||||
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||
final Set<String> changedUris = {};
|
||||
|
||||
// observers are not registered when using the same list object with different items
|
||||
// the list itself needs to be reassigned
|
||||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
|
||||
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initPlatformServices();
|
||||
_appSetup = _setup();
|
||||
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// place the settings provider above `MaterialApp`
|
||||
// so it can be used during navigation transitions
|
||||
return ChangeNotifierProvider<Settings>.value(
|
||||
value: settings,
|
||||
child: ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: appModeNotifier,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
||||
final home = initialized
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox(),
|
||||
);
|
||||
return Selector<Settings, Locale>(
|
||||
selector: (context, s) => s.locale,
|
||||
builder: (context, settingsLocale, child) {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
locale: settingsLocale,
|
||||
localizationsDelegates: [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(Object error) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(AIcons.error),
|
||||
SizedBox(height: 16),
|
||||
Text(error.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) {
|
||||
final crashlytics = FirebaseCrashlytics.instance;
|
||||
FlutterError.onError = crashlytics.recordFlutterError;
|
||||
crashlytics.setCustomKey('locales', window.locales.join(', '));
|
||||
final now = DateTime.now();
|
||||
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
|
||||
crashlytics.setCustomKey(
|
||||
'build_mode',
|
||||
kReleaseMode
|
||||
? 'release'
|
||||
: kProfileMode
|
||||
? 'profile'
|
||||
: 'debug');
|
||||
});
|
||||
await settings.init();
|
||||
await settings.initFirebase();
|
||||
_navigatorObservers = [
|
||||
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
|
||||
CrashlyticsRouteTracker(),
|
||||
];
|
||||
}
|
||||
|
||||
void _onNewIntent(Map intentData) {
|
||||
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
|
||||
|
||||
// do not reset when relaunching the app
|
||||
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
|
||||
|
||||
FirebaseCrashlytics.instance.log('New intent');
|
||||
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
|
||||
settings: RouteSettings(name: HomePage.routeName),
|
||||
builder: (_) => getFirstPage(intentData: intentData),
|
||||
));
|
||||
}
|
||||
|
||||
void _onContentChange(String uri) {
|
||||
if (uri != null) changedUris.add(uri);
|
||||
if (changedUris.isNotEmpty) {
|
||||
_contentChangeDebouncer(() async {
|
||||
final todo = changedUris.toSet();
|
||||
changedUris.clear();
|
||||
final tempUris = await _mediaStoreSource.refreshUris(todo);
|
||||
if (tempUris.isNotEmpty) {
|
||||
changedUris.addAll(tempUris);
|
||||
_onContentChange(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ extension ExtraChipAction on ChipAction {
|
|||
case ChipAction.setCover:
|
||||
return context.l10n.chipActionSetCover;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
|
@ -65,6 +64,5 @@ extension ExtraChipAction on ChipAction {
|
|||
case ChipAction.setCover:
|
||||
return AIcons.setCover;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,10 +91,9 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.debug:
|
||||
return 'Debug';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
IconData? getIcon() {
|
||||
switch (this) {
|
||||
// in app actions
|
||||
case EntryAction.toggleFavourite:
|
||||
|
@ -129,6 +128,5 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.debug:
|
||||
return AIcons.debug;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:github/github.dart';
|
||||
import 'package:google_api_availability/google_api_availability.dart';
|
||||
import 'package:package_info/package_info.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
abstract class AvesAvailability {
|
||||
|
@ -21,7 +21,7 @@ abstract class AvesAvailability {
|
|||
}
|
||||
|
||||
class LiveAvesAvailability implements AvesAvailability {
|
||||
bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
|
||||
bool? _isConnected, _hasPlayServices, _isNewVersionAvailable;
|
||||
|
||||
LiveAvesAvailability() {
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
|
||||
|
@ -32,10 +32,10 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected);
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected!);
|
||||
final result = await (Connectivity().checkConnectivity());
|
||||
_updateConnectivityFromResult(result);
|
||||
return _isConnected;
|
||||
return _isConnected!;
|
||||
}
|
||||
|
||||
void _updateConnectivityFromResult(ConnectivityResult result) {
|
||||
|
@ -48,11 +48,11 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
|
||||
@override
|
||||
Future<bool> get hasPlayServices async {
|
||||
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
|
||||
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices!);
|
||||
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
|
||||
_hasPlayServices = result == GooglePlayServicesAvailability.success;
|
||||
debugPrint('Device has Play Services=$_hasPlayServices');
|
||||
return _hasPlayServices;
|
||||
return _hasPlayServices!;
|
||||
}
|
||||
|
||||
// local geocoding with `geocoder` requires Play Services
|
||||
|
@ -61,27 +61,27 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
|
||||
@override
|
||||
Future<bool> get isNewVersionAvailable async {
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
|
||||
|
||||
final now = DateTime.now();
|
||||
final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval);
|
||||
if (now.isBefore(dueDate)) {
|
||||
_isNewVersionAvailable = false;
|
||||
return SynchronousFuture(_isNewVersionAvailable);
|
||||
return SynchronousFuture(_isNewVersionAvailable!);
|
||||
}
|
||||
|
||||
if (!(await isConnected)) return false;
|
||||
|
||||
Version version(String s) => Version.parse(s.replaceFirst('v', ''));
|
||||
final currentTag = (await PackageInfo.fromPlatform()).version;
|
||||
final latestTag = (await GitHub().repositories.getLatestRelease(RepositorySlug('deckerst', 'aves'))).tagName;
|
||||
final latestTag = (await GitHub().repositories.getLatestRelease(RepositorySlug('deckerst', 'aves'))).tagName!;
|
||||
_isNewVersionAvailable = version(latestTag) > version(currentTag);
|
||||
if (_isNewVersionAvailable) {
|
||||
if (_isNewVersionAvailable!) {
|
||||
debugPrint('Aves $latestTag is available on github');
|
||||
} else {
|
||||
debugPrint('Aves $currentTag is the latest version');
|
||||
settings.lastVersionCheckDate = now;
|
||||
}
|
||||
return _isNewVersionAvailable;
|
||||
return _isNewVersionAvailable!;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -18,19 +19,19 @@ class Covers with ChangeNotifier {
|
|||
|
||||
int get count => _rows.length;
|
||||
|
||||
int coverContentId(CollectionFilter filter) => _rows.firstWhere((row) => row.filter == filter, orElse: () => null)?.contentId;
|
||||
int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId;
|
||||
|
||||
Future<void> set(CollectionFilter filter, int contentId) async {
|
||||
Future<void> set(CollectionFilter filter, int? contentId) async {
|
||||
// erase contextual properties from filters before saving them
|
||||
if (filter is AlbumFilter) {
|
||||
filter = AlbumFilter((filter as AlbumFilter).album, null);
|
||||
filter = AlbumFilter(filter.album, null);
|
||||
}
|
||||
|
||||
final row = CoverRow(filter: filter, contentId: contentId);
|
||||
_rows.removeWhere((row) => row.filter == filter);
|
||||
if (contentId == null) {
|
||||
await metadataDb.removeCovers({row});
|
||||
await metadataDb.removeCovers({filter});
|
||||
} else {
|
||||
final row = CoverRow(filter: filter, contentId: contentId);
|
||||
_rows.add(row);
|
||||
await metadataDb.addCovers({row});
|
||||
}
|
||||
|
@ -46,11 +47,11 @@ class Covers with ChangeNotifier {
|
|||
final filter = oldRow.filter;
|
||||
_rows.remove(oldRow);
|
||||
if (filter.test(entry)) {
|
||||
final newRow = CoverRow(filter: filter, contentId: entry.contentId);
|
||||
final newRow = CoverRow(filter: filter, contentId: entry.contentId!);
|
||||
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
|
||||
_rows.add(newRow);
|
||||
} else {
|
||||
await metadataDb.removeCovers({oldRow});
|
||||
await metadataDb.removeCovers({filter});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +62,7 @@ class Covers with ChangeNotifier {
|
|||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
|
||||
|
||||
await metadataDb.removeCovers(removedRows);
|
||||
await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet());
|
||||
_rows.removeAll(removedRows);
|
||||
|
||||
notifyListeners();
|
||||
|
@ -81,13 +82,15 @@ class CoverRow {
|
|||
final int contentId;
|
||||
|
||||
const CoverRow({
|
||||
@required this.filter,
|
||||
@required this.contentId,
|
||||
required this.filter,
|
||||
required this.contentId,
|
||||
});
|
||||
|
||||
factory CoverRow.fromMap(Map map) {
|
||||
static CoverRow? fromMap(Map map) {
|
||||
final filter = CollectionFilter.fromJson(map['filter']);
|
||||
if (filter == null) return null;
|
||||
return CoverRow(
|
||||
filter: CollectionFilter.fromJson(map['filter']),
|
||||
filter: filter,
|
||||
contentId: map['contentId'],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,22 +22,22 @@ import '../ref/mime_types.dart';
|
|||
|
||||
class AvesEntry {
|
||||
String uri;
|
||||
String _path, _directory, _filename, _extension;
|
||||
int pageId, contentId;
|
||||
String? _path, _directory, _filename, _extension;
|
||||
int? pageId, contentId;
|
||||
final String sourceMimeType;
|
||||
int width;
|
||||
int height;
|
||||
int sourceRotationDegrees;
|
||||
final int sizeBytes;
|
||||
String _sourceTitle;
|
||||
final int? sizeBytes;
|
||||
String? _sourceTitle;
|
||||
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
int _dateModifiedSecs;
|
||||
final int sourceDateTakenMillis;
|
||||
final int durationMillis;
|
||||
int _catalogDateMillis;
|
||||
CatalogMetadata _catalogMetadata;
|
||||
AddressDetails _addressDetails;
|
||||
int? _dateModifiedSecs;
|
||||
final int? sourceDateTakenMillis;
|
||||
final int? durationMillis;
|
||||
int? _catalogDateMillis;
|
||||
CatalogMetadata? _catalogMetadata;
|
||||
AddressDetails? _addressDetails;
|
||||
|
||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
|
@ -51,21 +51,20 @@ class AvesEntry {
|
|||
];
|
||||
|
||||
AvesEntry({
|
||||
this.uri,
|
||||
String path,
|
||||
this.contentId,
|
||||
this.pageId,
|
||||
this.sourceMimeType,
|
||||
@required this.width,
|
||||
@required this.height,
|
||||
this.sourceRotationDegrees,
|
||||
this.sizeBytes,
|
||||
String sourceTitle,
|
||||
int dateModifiedSecs,
|
||||
this.sourceDateTakenMillis,
|
||||
this.durationMillis,
|
||||
}) : assert(width != null),
|
||||
assert(height != null) {
|
||||
required this.uri,
|
||||
required String? path,
|
||||
required this.contentId,
|
||||
required this.pageId,
|
||||
required this.sourceMimeType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.sourceRotationDegrees,
|
||||
required this.sizeBytes,
|
||||
required String? sourceTitle,
|
||||
required int? dateModifiedSecs,
|
||||
required this.sourceDateTakenMillis,
|
||||
required this.durationMillis,
|
||||
}) {
|
||||
this.path = path;
|
||||
this.sourceTitle = sourceTitle;
|
||||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -76,16 +75,17 @@ class AvesEntry {
|
|||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
AvesEntry copyWith({
|
||||
String uri,
|
||||
String path,
|
||||
int contentId,
|
||||
int dateModifiedSecs,
|
||||
String? uri,
|
||||
String? path,
|
||||
int? contentId,
|
||||
int? dateModifiedSecs,
|
||||
}) {
|
||||
final copyContentId = contentId ?? this.contentId;
|
||||
final copied = AvesEntry(
|
||||
uri: uri ?? this.uri,
|
||||
path: path ?? this.path,
|
||||
contentId: copyContentId,
|
||||
pageId: null,
|
||||
sourceMimeType: sourceMimeType,
|
||||
width: width,
|
||||
height: height,
|
||||
|
@ -106,17 +106,18 @@ class AvesEntry {
|
|||
factory AvesEntry.fromMap(Map map) {
|
||||
return AvesEntry(
|
||||
uri: map['uri'] as String,
|
||||
path: map['path'] as String,
|
||||
contentId: map['contentId'] as int,
|
||||
path: map['path'] as String?,
|
||||
pageId: null,
|
||||
contentId: map['contentId'] as int?,
|
||||
sourceMimeType: map['sourceMimeType'] as String,
|
||||
width: map['width'] as int ?? 0,
|
||||
height: map['height'] as int ?? 0,
|
||||
sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0,
|
||||
sizeBytes: map['sizeBytes'] as int,
|
||||
sourceTitle: map['title'] as String,
|
||||
dateModifiedSecs: map['dateModifiedSecs'] as int,
|
||||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int,
|
||||
durationMillis: map['durationMillis'] as int,
|
||||
width: map['width'] as int? ?? 0,
|
||||
height: map['height'] as int? ?? 0,
|
||||
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
|
||||
sizeBytes: map['sizeBytes'] as int?,
|
||||
sourceTitle: map['title'] as String?,
|
||||
dateModifiedSecs: map['dateModifiedSecs'] as int?,
|
||||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
|
||||
durationMillis: map['durationMillis'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -150,27 +151,27 @@ class AvesEntry {
|
|||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
||||
|
||||
set path(String path) {
|
||||
set path(String? path) {
|
||||
_path = path;
|
||||
_directory = null;
|
||||
_filename = null;
|
||||
_extension = null;
|
||||
}
|
||||
|
||||
String get path => _path;
|
||||
String? get path => _path;
|
||||
|
||||
String get directory {
|
||||
_directory ??= path != null ? pContext.dirname(path) : null;
|
||||
String? get directory {
|
||||
_directory ??= path != null ? pContext.dirname(path!) : null;
|
||||
return _directory;
|
||||
}
|
||||
|
||||
String get filenameWithoutExtension {
|
||||
_filename ??= path != null ? pContext.basenameWithoutExtension(path) : null;
|
||||
String? get filenameWithoutExtension {
|
||||
_filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
|
||||
return _filename;
|
||||
}
|
||||
|
||||
String get extension {
|
||||
_extension ??= path != null ? pContext.extension(path) : null;
|
||||
String? get extension {
|
||||
_extension ??= path != null ? pContext.extension(path!) : null;
|
||||
return _extension;
|
||||
}
|
||||
|
||||
|
@ -258,16 +259,16 @@ class AvesEntry {
|
|||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
bool get isSized => (width ?? 0) > 0 && (height ?? 0) > 0;
|
||||
bool get isSized => width > 0 && height > 0;
|
||||
|
||||
String get resolutionText {
|
||||
final ws = width ?? '?';
|
||||
final hs = height ?? '?';
|
||||
final ws = width;
|
||||
final hs = height;
|
||||
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||
}
|
||||
|
||||
String get aspectRatioText {
|
||||
if (width != null && height != null && width > 0 && height > 0) {
|
||||
if (width > 0 && height > 0) {
|
||||
final gcd = width.gcd(height);
|
||||
final w = width ~/ gcd;
|
||||
final h = height ~/ gcd;
|
||||
|
@ -288,24 +289,36 @@ class AvesEntry {
|
|||
return isRotated ? Size(h, w) : Size(w, h);
|
||||
}
|
||||
|
||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||
Size videoDisplaySize(double sar) {
|
||||
final size = displaySize;
|
||||
if (sar != 1) {
|
||||
final dar = displayAspectRatio * sar;
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
if (w >= h) return Size(w, w / dar);
|
||||
if (h > w) return Size(h * dar, h);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
DateTime _bestDate;
|
||||
int get megaPixels => (width * height / 1000000).round();
|
||||
|
||||
DateTime get bestDate {
|
||||
DateTime? _bestDate;
|
||||
|
||||
DateTime? get bestDate {
|
||||
if (_bestDate == null) {
|
||||
if ((_catalogDateMillis ?? 0) > 0) {
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis);
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis!);
|
||||
} else if ((sourceDateTakenMillis ?? 0) > 0) {
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis);
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis!);
|
||||
} else if ((dateModifiedSecs ?? 0) > 0) {
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
|
||||
_bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs! * 1000);
|
||||
}
|
||||
}
|
||||
return _bestDate;
|
||||
}
|
||||
|
||||
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
|
||||
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
|
||||
|
||||
set rotationDegrees(int rotationDegrees) {
|
||||
sourceRotationDegrees = rotationDegrees;
|
||||
|
@ -316,78 +329,78 @@ class AvesEntry {
|
|||
|
||||
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
||||
|
||||
String get sourceTitle => _sourceTitle;
|
||||
String? get sourceTitle => _sourceTitle;
|
||||
|
||||
set sourceTitle(String sourceTitle) {
|
||||
set sourceTitle(String? sourceTitle) {
|
||||
_sourceTitle = sourceTitle;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
int get dateModifiedSecs => _dateModifiedSecs;
|
||||
int? get dateModifiedSecs => _dateModifiedSecs;
|
||||
|
||||
set dateModifiedSecs(int dateModifiedSecs) {
|
||||
set dateModifiedSecs(int? dateModifiedSecs) {
|
||||
_dateModifiedSecs = dateModifiedSecs;
|
||||
_bestDate = null;
|
||||
}
|
||||
|
||||
DateTime get monthTaken {
|
||||
DateTime? get monthTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month);
|
||||
}
|
||||
|
||||
DateTime get dayTaken {
|
||||
DateTime? get dayTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month, d.day);
|
||||
}
|
||||
|
||||
String _durationText;
|
||||
String? _durationText;
|
||||
|
||||
String get durationText {
|
||||
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
|
||||
return _durationText;
|
||||
return _durationText!;
|
||||
}
|
||||
|
||||
// returns whether this entry has GPS coordinates
|
||||
// (0, 0) coordinates are considered invalid, as it is likely a default value
|
||||
bool get hasGps => _catalogMetadata != null && _catalogMetadata.latitude != null && _catalogMetadata.longitude != null && (_catalogMetadata.latitude != 0 || _catalogMetadata.longitude != 0);
|
||||
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
|
||||
|
||||
bool get hasAddress => _addressDetails != null;
|
||||
|
||||
// has a place, or at least the full country name
|
||||
// derived from Google reverse geocoding addresses
|
||||
bool get hasFineAddress => _addressDetails != null && (_addressDetails.place?.isNotEmpty == true || (_addressDetails.countryName?.length ?? 0) > 3);
|
||||
bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3;
|
||||
|
||||
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||
|
||||
String get geoUri {
|
||||
String? get geoUri {
|
||||
if (!hasGps) return null;
|
||||
final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6);
|
||||
final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6);
|
||||
final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
|
||||
final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
|
||||
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
|
||||
}
|
||||
|
||||
List<String> _xmpSubjects;
|
||||
List<String>? _xmpSubjects;
|
||||
|
||||
List<String> get xmpSubjects {
|
||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
||||
return _xmpSubjects;
|
||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
|
||||
return _xmpSubjects!;
|
||||
}
|
||||
|
||||
String _bestTitle;
|
||||
String? _bestTitle;
|
||||
|
||||
String get bestTitle {
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||
String? get bestTitle {
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle;
|
||||
return _bestTitle;
|
||||
}
|
||||
|
||||
CatalogMetadata get catalogMetadata => _catalogMetadata;
|
||||
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
||||
|
||||
set catalogDateMillis(int dateMillis) {
|
||||
set catalogDateMillis(int? dateMillis) {
|
||||
_catalogDateMillis = dateMillis;
|
||||
_bestDate = null;
|
||||
}
|
||||
|
||||
set catalogMetadata(CatalogMetadata newMetadata) {
|
||||
set catalogMetadata(CatalogMetadata? newMetadata) {
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
|
@ -424,14 +437,14 @@ class AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
AddressDetails get addressDetails => _addressDetails;
|
||||
AddressDetails? get addressDetails => _addressDetails;
|
||||
|
||||
set addressDetails(AddressDetails newAddress) {
|
||||
set addressDetails(AddressDetails? newAddress) {
|
||||
_addressDetails = newAddress;
|
||||
addressChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> locate({@required bool background}) async {
|
||||
Future<void> locate({required bool background}) async {
|
||||
if (!hasGps) return;
|
||||
await _locateCountry();
|
||||
if (await availability.canLocatePlaces) {
|
||||
|
@ -442,11 +455,11 @@ class AvesEntry {
|
|||
// quick reverse geocoding to find the country, using an offline asset
|
||||
Future<void> _locateCountry() async {
|
||||
if (!hasGps || hasAddress) return;
|
||||
final countryCode = await countryTopology.countryCode(latLng);
|
||||
final countryCode = await countryTopology.countryCode(latLng!);
|
||||
setCountry(countryCode);
|
||||
}
|
||||
|
||||
void setCountry(CountryCode countryCode) {
|
||||
void setCountry(CountryCode? countryCode) {
|
||||
if (hasFineAddress || countryCode == null) return;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
|
@ -455,25 +468,25 @@ class AvesEntry {
|
|||
);
|
||||
}
|
||||
|
||||
String _geocoderLocale;
|
||||
String? _geocoderLocale;
|
||||
|
||||
String get geocoderLocale {
|
||||
_geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance.window.locale).toString();
|
||||
return _geocoderLocale;
|
||||
_geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString();
|
||||
return _geocoderLocale!;
|
||||
}
|
||||
|
||||
// full reverse geocoding, requiring Play Services and some connectivity
|
||||
Future<void> locatePlace({@required bool background}) async {
|
||||
Future<void> locatePlace({required bool background}) async {
|
||||
if (!hasGps || hasFineAddress) return;
|
||||
try {
|
||||
Future<List<Address>> call() => GeocodingService.getAddress(latLng, geocoderLocale);
|
||||
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
|
||||
final addresses = await (background
|
||||
? servicePolicy.call(
|
||||
call,
|
||||
priority: ServiceCallPriority.getLocation,
|
||||
)
|
||||
: call());
|
||||
if (addresses != null && addresses.isNotEmpty) {
|
||||
if (addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
final cc = address.countryCode;
|
||||
final cn = address.countryName;
|
||||
|
@ -493,12 +506,12 @@ class AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String> findAddressLine() async {
|
||||
Future<String?> findAddressLine() async {
|
||||
if (!hasGps) return null;
|
||||
|
||||
try {
|
||||
final addresses = await GeocodingService.getAddress(latLng, geocoderLocale);
|
||||
if (addresses != null && addresses.isNotEmpty) {
|
||||
final addresses = await GeocodingService.getAddress(latLng!, geocoderLocale);
|
||||
if (addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
return address.addressLine;
|
||||
}
|
||||
|
@ -549,12 +562,12 @@ class AvesEntry {
|
|||
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||||
|
||||
await metadataDb.saveEntries({this});
|
||||
await metadataDb.saveMetadata({catalogMetadata});
|
||||
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
|
||||
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> rotate({@required bool clockwise}) async {
|
||||
Future<bool> rotate({required bool clockwise}) async {
|
||||
final newFields = await imageFileService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
|
@ -579,7 +592,7 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
Future<bool> delete() {
|
||||
Completer completer = Completer<bool>();
|
||||
final completer = Completer<bool>();
|
||||
imageFileService.delete([this]).listen(
|
||||
(event) => completer.complete(event.success),
|
||||
onError: completer.completeError,
|
||||
|
@ -593,7 +606,7 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
// when the entry image itself changed (e.g. after rotation)
|
||||
Future<void> _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||||
Future<void> _onImageChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||||
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
imageChangeNotifier.notifyListeners();
|
||||
|
@ -626,15 +639,15 @@ class AvesEntry {
|
|||
// 1) title ascending
|
||||
// 2) extension ascending
|
||||
static int compareByName(AvesEntry a, AvesEntry b) {
|
||||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||||
final c = compareAsciiUpperCase(a.bestTitle ?? '', b.bestTitle ?? '');
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
|
||||
}
|
||||
|
||||
// compare by:
|
||||
// 1) size descending
|
||||
// 2) name ascending
|
||||
static int compareBySize(AvesEntry a, AvesEntry b) {
|
||||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||||
final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
|
||||
return c != 0 ? c : compareByName(a, b);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,29 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
|
||||
class EntryCache {
|
||||
// ordered descending
|
||||
static final thumbnailRequestExtents = <double>[];
|
||||
|
||||
static void markThumbnailExtent(double extent) {
|
||||
if (!thumbnailRequestExtents.contains(extent)) {
|
||||
thumbnailRequestExtents
|
||||
..add(extent)
|
||||
..sort((a, b) => b.compareTo(a));
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> evict(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int dateModifiedSecs,
|
||||
int? dateModifiedSecs,
|
||||
int oldRotationDegrees,
|
||||
bool oldIsFlipped,
|
||||
) async {
|
||||
// TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them
|
||||
int pageId;
|
||||
int? pageId;
|
||||
|
||||
// evict fullscreen image
|
||||
await UriImage(
|
||||
|
@ -29,20 +39,18 @@ class EntryCache {
|
|||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
dateModifiedSecs: dateModifiedSecs ?? 0,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
)).evict();
|
||||
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
|
||||
await Future.forEach<double>(
|
||||
extents,
|
||||
thumbnailRequestExtents,
|
||||
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
dateModifiedSecs: dateModifiedSecs ?? 0,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
extent: extent,
|
||||
|
|
|
@ -5,7 +5,8 @@ import 'package:aves/image_providers/region_provider.dart';
|
|||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
|
@ -14,11 +15,7 @@ extension ExtraAvesEntry on AvesEntry {
|
|||
}
|
||||
|
||||
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
||||
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
||||
// so that there are less variants of the thumbnails to load and cache
|
||||
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
||||
final requestExtent = extent == 0 ? .0 : pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
||||
|
||||
EntryCache.markThumbnailExtent(extent);
|
||||
return ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
|
@ -26,15 +23,15 @@ extension ExtraAvesEntry on AvesEntry {
|
|||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
dateModifiedSecs: dateModifiedSecs ?? -1,
|
||||
extent: requestExtent,
|
||||
extent: extent,
|
||||
);
|
||||
}
|
||||
|
||||
RegionProvider getRegion({@required int sampleSize, Rectangle<int> region}) {
|
||||
RegionProvider getRegion({required int sampleSize, Rectangle<int>? region}) {
|
||||
return RegionProvider(_getRegionProviderKey(sampleSize, region));
|
||||
}
|
||||
|
||||
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int> region) {
|
||||
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int>? region) {
|
||||
return RegionProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
|
@ -56,12 +53,12 @@ extension ExtraAvesEntry on AvesEntry {
|
|||
expectedContentLength: sizeBytes,
|
||||
);
|
||||
|
||||
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive;
|
||||
bool _isReady(Object providerKey) => imageCache!.statusForKey(providerKey).keepAlive;
|
||||
|
||||
ImageProvider getBestThumbnail(double extent) {
|
||||
final sizedThumbnailKey = _getThumbnailProviderKey(extent);
|
||||
if (_isReady(sizedThumbnailKey)) return ThumbnailProvider(sizedThumbnailKey);
|
||||
List<ThumbnailProvider> get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map((key) => ThumbnailProvider(key)).toList();
|
||||
|
||||
return getThumbnail();
|
||||
ThumbnailProvider get bestCachedThumbnail {
|
||||
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);
|
||||
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -18,7 +19,7 @@ class Favourites with ChangeNotifier {
|
|||
|
||||
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
||||
|
||||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
|
||||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!);
|
||||
|
||||
Future<void> add(Iterable<AvesEntry> entries) async {
|
||||
final newRows = entries.map(_entryToRow);
|
||||
|
@ -40,7 +41,7 @@ class Favourites with ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
|
||||
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
|
||||
final oldRow = _rows.firstWhereOrNull((row) => row.contentId == oldContentId);
|
||||
if (oldRow == null) return;
|
||||
|
||||
final newRow = _entryToRow(entry);
|
||||
|
@ -66,13 +67,13 @@ class FavouriteRow {
|
|||
final String path;
|
||||
|
||||
const FavouriteRow({
|
||||
this.contentId,
|
||||
this.path,
|
||||
required this.contentId,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
factory FavouriteRow.fromMap(Map map) {
|
||||
return FavouriteRow(
|
||||
contentId: map['contentId'],
|
||||
contentId: map['contentId'] ?? 0,
|
||||
path: map['path'] ?? '',
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ class AlbumFilter extends CollectionFilter {
|
|||
static final Map<String, Color> _appColors = {};
|
||||
|
||||
final String album;
|
||||
final String displayName;
|
||||
final String? displayName;
|
||||
|
||||
const AlbumFilter(this.album, this.displayName);
|
||||
|
||||
|
@ -41,10 +41,10 @@ class AlbumFilter extends CollectionFilter {
|
|||
String getTooltip(BuildContext context) => album;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
return IconUtils.getAlbumIcon(
|
||||
context: context,
|
||||
album: album,
|
||||
albumPath: album,
|
||||
size: size,
|
||||
embossed: embossed,
|
||||
) ??
|
||||
|
@ -56,21 +56,20 @@ class AlbumFilter extends CollectionFilter {
|
|||
// do not use async/await and rely on `SynchronousFuture`
|
||||
// to prevent rebuilding of the `FutureBuilder` listening on this future
|
||||
if (androidFileUtils.getAlbumType(album) == AlbumType.app) {
|
||||
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]);
|
||||
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
|
||||
|
||||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(
|
||||
packageName: androidFileUtils.getAlbumAppPackageName(album),
|
||||
size: 24,
|
||||
),
|
||||
).then((palette) {
|
||||
final color = palette.dominantColor?.color ?? super.color(context);
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
} else {
|
||||
return super.color(context);
|
||||
final packageName = androidFileUtils.getAlbumAppPackageName(album);
|
||||
if (packageName != null) {
|
||||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(packageName: packageName, size: 24),
|
||||
).then((palette) async {
|
||||
final color = palette.dominantColor?.color ?? (await super.color(context));
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
}
|
||||
}
|
||||
return super.color(context);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -24,7 +24,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
TagFilter.type,
|
||||
];
|
||||
|
||||
static CollectionFilter fromJson(String jsonString) {
|
||||
static CollectionFilter? fromJson(String jsonString) {
|
||||
final jsonMap = jsonDecode(jsonString);
|
||||
final type = jsonMap['type'];
|
||||
switch (type) {
|
||||
|
@ -63,7 +63,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
String getTooltip(BuildContext context) => getLabel(context);
|
||||
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
|
||||
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
|
||||
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));
|
||||
|
||||
|
@ -84,7 +84,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
class FilterGridItem<T extends CollectionFilter> {
|
||||
final T filter;
|
||||
final AvesEntry entry;
|
||||
final AvesEntry? entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -10,8 +11,8 @@ class LocationFilter extends CollectionFilter {
|
|||
|
||||
final LocationLevel level;
|
||||
String _location;
|
||||
String _countryCode;
|
||||
EntryFilter _test;
|
||||
String? _countryCode;
|
||||
late EntryFilter _test;
|
||||
|
||||
LocationFilter(this.level, this._location) {
|
||||
final split = _location.split(locationSeparator);
|
||||
|
@ -29,7 +30,7 @@ class LocationFilter extends CollectionFilter {
|
|||
|
||||
LocationFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null),
|
||||
LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
|
||||
json['location'],
|
||||
);
|
||||
|
||||
|
@ -42,7 +43,7 @@ class LocationFilter extends CollectionFilter {
|
|||
|
||||
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
||||
|
||||
String get countryCode => _countryCode;
|
||||
String? get countryCode => _countryCode;
|
||||
|
||||
@override
|
||||
EntryFilter get test => _test;
|
||||
|
@ -90,8 +91,9 @@ class LocationFilter extends CollectionFilter {
|
|||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;
|
||||
|
||||
static String countryCodeToFlag(String code) {
|
||||
return code?.length == 2 ? String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff)) : null;
|
||||
static String? countryCodeToFlag(String? code) {
|
||||
if (code == null || code.length != 2) return null;
|
||||
return String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,9 +10,9 @@ class MimeFilter extends CollectionFilter {
|
|||
static const type = 'mime';
|
||||
|
||||
final String mime;
|
||||
EntryFilter _test;
|
||||
String _label;
|
||||
IconData _icon;
|
||||
late EntryFilter _test;
|
||||
late String _label;
|
||||
IconData? /*late*/ _icon;
|
||||
|
||||
static final image = MimeFilter(MimeTypes.anyImage);
|
||||
static final video = MimeFilter(MimeTypes.anyVideo);
|
||||
|
|
|
@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
|
|||
|
||||
final String query;
|
||||
final bool colorful;
|
||||
EntryFilter _test;
|
||||
late EntryFilter _test;
|
||||
|
||||
QueryFilter(this.query, {this.colorful = true}) {
|
||||
var upQuery = query.toUpperCase();
|
||||
|
@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter {
|
|||
// allow untrimmed queries wrapped with `"..."`
|
||||
final matches = exactRegex.allMatches(upQuery);
|
||||
if (matches.length == 1) {
|
||||
upQuery = matches.first.group(1);
|
||||
upQuery = matches.first.group(1)!;
|
||||
}
|
||||
|
||||
_test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
|
||||
|
|
|
@ -8,7 +8,7 @@ class TagFilter extends CollectionFilter {
|
|||
static const type = 'tag';
|
||||
|
||||
final String tag;
|
||||
EntryFilter _test;
|
||||
late EntryFilter _test;
|
||||
|
||||
TagFilter(this.tag) {
|
||||
if (tag.isEmpty) {
|
||||
|
@ -42,7 +42,7 @@ class TagFilter extends CollectionFilter {
|
|||
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
|
||||
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
|
||||
|
||||
@override
|
||||
String get category => type;
|
||||
|
|
|
@ -14,8 +14,8 @@ class TypeFilter extends CollectionFilter {
|
|||
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
||||
|
||||
final String itemType;
|
||||
EntryFilter _test;
|
||||
IconData _icon;
|
||||
late EntryFilter _test;
|
||||
IconData? /*late*/ _icon;
|
||||
|
||||
static final animated = TypeFilter._private(_animated);
|
||||
static final geotiff = TypeFilter._private(_geotiff);
|
||||
|
|
|
@ -1,7 +1,29 @@
|
|||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class HighlightInfo extends ChangeNotifier {
|
||||
Object _item;
|
||||
final EventBus eventBus = EventBus();
|
||||
|
||||
void trackItem<T>(
|
||||
T? item, {
|
||||
TrackPredicate? predicate,
|
||||
Alignment? alignment,
|
||||
bool? animate,
|
||||
Object? highlightItem,
|
||||
}) {
|
||||
if (item != null) {
|
||||
eventBus.fire(TrackEvent<T>(
|
||||
item,
|
||||
predicate ?? (_) => true,
|
||||
alignment ?? Alignment.center,
|
||||
animate ?? true,
|
||||
highlightItem,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Object? _item;
|
||||
|
||||
void set(Object item) {
|
||||
if (_item == item) return;
|
||||
|
@ -9,7 +31,7 @@ class HighlightInfo extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Object clear() {
|
||||
Object? clear() {
|
||||
if (_item == null) return null;
|
||||
final item = _item;
|
||||
_item = null;
|
||||
|
@ -22,3 +44,24 @@ class HighlightInfo extends ChangeNotifier {
|
|||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
class TrackEvent<T> {
|
||||
final T item;
|
||||
final TrackPredicate predicate;
|
||||
final Alignment alignment;
|
||||
final bool animate;
|
||||
final Object? highlightItem;
|
||||
|
||||
const TrackEvent(
|
||||
this.item,
|
||||
this.predicate,
|
||||
this.alignment,
|
||||
this.animate,
|
||||
this.highlightItem,
|
||||
);
|
||||
}
|
||||
|
||||
// `itemVisibility`: percent of the item tracked already visible in viewport
|
||||
// return whether to proceed with tracking
|
||||
typedef TrackPredicate = bool Function(double itemVisibility);
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateMetadata {
|
||||
final int contentId, dateMillis;
|
||||
final int? contentId, dateMillis;
|
||||
|
||||
DateMetadata({
|
||||
this.contentId,
|
||||
|
@ -28,13 +28,13 @@ class DateMetadata {
|
|||
}
|
||||
|
||||
class CatalogMetadata {
|
||||
final int contentId, dateMillis;
|
||||
final int? contentId, dateMillis;
|
||||
final bool isAnimated, isGeotiff, is360, isMultiPage;
|
||||
bool isFlipped;
|
||||
int rotationDegrees;
|
||||
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||
double latitude, longitude;
|
||||
Address address;
|
||||
int? rotationDegrees;
|
||||
final String? mimeType, xmpSubjects, xmpTitleDescription;
|
||||
double? latitude, longitude;
|
||||
Address? address;
|
||||
|
||||
static const double _precisionErrorTolerance = 1e-9;
|
||||
static const _isAnimatedMask = 1 << 0;
|
||||
|
@ -55,23 +55,28 @@ class CatalogMetadata {
|
|||
this.rotationDegrees,
|
||||
this.xmpSubjects,
|
||||
this.xmpTitleDescription,
|
||||
double latitude,
|
||||
double longitude,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky values like `1.7056881853375E7`
|
||||
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7`
|
||||
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
|
||||
// but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case.
|
||||
if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) {
|
||||
this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude;
|
||||
this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude;
|
||||
// funny case: some files have latitude and longitude reverse
|
||||
// (e.g. a Japanese location at lat~=133 and long~=34, which is a valid longitude but an invalid latitude)
|
||||
// so we should check and assign both coordinates at once
|
||||
if (latitude >= -90.0 && latitude <= 90.0 && longitude >= -180.0 && longitude <= 180.0) {
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CatalogMetadata copyWith({
|
||||
int contentId,
|
||||
String mimeType,
|
||||
bool isMultiPage,
|
||||
int rotationDegrees,
|
||||
int? contentId,
|
||||
String? mimeType,
|
||||
bool? isMultiPage,
|
||||
int? rotationDegrees,
|
||||
}) {
|
||||
return CatalogMetadata(
|
||||
contentId: contentId ?? this.contentId,
|
||||
|
@ -127,16 +132,16 @@ class CatalogMetadata {
|
|||
}
|
||||
|
||||
class OverlayMetadata {
|
||||
final String aperture, exposureTime, focalLength, iso;
|
||||
final String? aperture, exposureTime, focalLength, iso;
|
||||
|
||||
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
||||
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
||||
|
||||
OverlayMetadata({
|
||||
double aperture,
|
||||
String exposureTime,
|
||||
double focalLength,
|
||||
int iso,
|
||||
double? aperture,
|
||||
String? exposureTime,
|
||||
double? focalLength,
|
||||
int? iso,
|
||||
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
|
||||
exposureTime = exposureTime,
|
||||
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
|
||||
|
@ -144,10 +149,10 @@ class OverlayMetadata {
|
|||
|
||||
factory OverlayMetadata.fromMap(Map map) {
|
||||
return OverlayMetadata(
|
||||
aperture: map['aperture'] as double,
|
||||
exposureTime: map['exposureTime'] as String,
|
||||
focalLength: map['focalLength'] as double,
|
||||
iso: map['iso'] as int,
|
||||
aperture: map['aperture'] as double?,
|
||||
exposureTime: map['exposureTime'] as String?,
|
||||
focalLength: map['focalLength'] as double?,
|
||||
iso: map['iso'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -159,10 +164,10 @@ class OverlayMetadata {
|
|||
|
||||
@immutable
|
||||
class AddressDetails {
|
||||
final int contentId;
|
||||
final String countryCode, countryName, adminArea, locality;
|
||||
final int? contentId;
|
||||
final String? countryCode, countryName, adminArea, locality;
|
||||
|
||||
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
|
||||
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
||||
|
||||
const AddressDetails({
|
||||
this.contentId,
|
||||
|
@ -173,7 +178,7 @@ class AddressDetails {
|
|||
});
|
||||
|
||||
AddressDetails copyWith({
|
||||
int contentId,
|
||||
int? contentId,
|
||||
}) {
|
||||
return AddressDetails(
|
||||
contentId: contentId ?? this.contentId,
|
||||
|
@ -186,11 +191,11 @@ class AddressDetails {
|
|||
|
||||
factory AddressDetails.fromMap(Map map) {
|
||||
return AddressDetails(
|
||||
contentId: map['contentId'],
|
||||
countryCode: map['countryCode'] ?? '',
|
||||
countryName: map['countryName'] ?? '',
|
||||
adminArea: map['adminArea'] ?? '',
|
||||
locality: map['locality'] ?? '',
|
||||
contentId: map['contentId'] as int?,
|
||||
countryCode: map['countryCode'] as String?,
|
||||
countryName: map['countryName'] as String?,
|
||||
adminArea: map['adminArea'] as String?,
|
||||
locality: map['locality'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
|
@ -16,7 +17,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly});
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
|
||||
|
||||
// entries
|
||||
|
||||
|
@ -40,9 +41,9 @@ abstract class MetadataDb {
|
|||
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries();
|
||||
|
||||
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries);
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata);
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata);
|
||||
|
||||
// address
|
||||
|
||||
|
@ -50,9 +51,9 @@ abstract class MetadataDb {
|
|||
|
||||
Future<List<AddressDetails>> loadAddresses();
|
||||
|
||||
Future<void> saveAddresses(Iterable<AddressDetails> addresses);
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
Future<void> updateAddressId(int oldId, AddressDetails address);
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address);
|
||||
|
||||
// favourites
|
||||
|
||||
|
@ -76,11 +77,11 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
||||
|
||||
Future<void> removeCovers(Iterable<CoverRow> rows);
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
||||
}
|
||||
|
||||
class SqfliteMetadataDb implements MetadataDb {
|
||||
Future<Database> _database;
|
||||
late Future<Database> _database;
|
||||
|
||||
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
|
||||
|
||||
|
@ -150,8 +151,8 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<int> dbFileSize() async {
|
||||
final file = File((await path));
|
||||
return await file.exists() ? file.length() : 0;
|
||||
final file = File(await path);
|
||||
return await file.exists() ? await file.length() : 0;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -163,8 +164,8 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) async {
|
||||
if (contentIds == null || contentIds.isEmpty) return;
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -207,7 +208,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||
if (entries == null || entries.isEmpty) return;
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
|
@ -226,7 +227,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertEntry(Batch batch, AvesEntry entry) {
|
||||
if (entry == null) return;
|
||||
batch.insert(
|
||||
entryTable,
|
||||
entry.toMap(),
|
||||
|
@ -273,13 +273,13 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
|
||||
if (metadataEntries == null || metadataEntries.isEmpty) return;
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) async {
|
||||
if (metadataEntries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
} catch (error, stack) {
|
||||
|
@ -288,7 +288,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
|
@ -297,7 +297,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertMetadata(Batch batch, CatalogMetadata metadata) {
|
||||
void _batchInsertMetadata(Batch batch, CatalogMetadata? metadata) {
|
||||
if (metadata == null) return;
|
||||
if (metadata.dateMillis != 0) {
|
||||
batch.insert(
|
||||
|
@ -333,18 +333,18 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async {
|
||||
if (addresses == null || addresses.isEmpty) return;
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses) async {
|
||||
if (addresses.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
addresses.where((address) => address != null).forEach((address) => _batchInsertAddress(batch, address));
|
||||
addresses.forEach((address) => _batchInsertAddress(batch, address));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAddressId(int oldId, AddressDetails address) async {
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
|
@ -352,7 +352,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertAddress(Batch batch, AddressDetails address) {
|
||||
void _batchInsertAddress(Batch batch, AddressDetails? address) {
|
||||
if (address == null) return;
|
||||
batch.insert(
|
||||
addressTable,
|
||||
|
@ -380,10 +380,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
if (rows.isEmpty) return;
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row));
|
||||
rows.forEach((row) => _batchInsertFavourite(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
@ -397,7 +397,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertFavourite(Batch batch, FavouriteRow row) {
|
||||
if (row == null) return;
|
||||
batch.insert(
|
||||
favouriteTable,
|
||||
row.toMap(),
|
||||
|
@ -407,8 +406,8 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
final ids = rows.where((row) => row != null).map((row) => row.contentId);
|
||||
if (rows.isEmpty) return;
|
||||
final ids = rows.map((row) => row.contentId);
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
|
@ -431,16 +430,16 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<Set<CoverRow>> loadCovers() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(coverTable);
|
||||
final rows = maps.map((map) => CoverRow.fromMap(map)).toSet();
|
||||
final rows = maps.map(CoverRow.fromMap).where((v) => v != null).cast<CoverRow>().toSet();
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
if (rows.isEmpty) return;
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.where((row) => row != null).forEach((row) => _batchInsertCover(batch, row));
|
||||
rows.forEach((row) => _batchInsertCover(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
@ -454,7 +453,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertCover(Batch batch, CoverRow row) {
|
||||
if (row == null) return;
|
||||
batch.insert(
|
||||
coverTable,
|
||||
row.toMap(),
|
||||
|
@ -463,9 +461,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeCovers(Iterable<CoverRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
final filters = rows.where((row) => row != null).map((row) => row.filter);
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters) async {
|
||||
if (filters.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MultiPageInfo {
|
||||
|
@ -11,8 +12,8 @@ class MultiPageInfo {
|
|||
int get pageCount => _pages.length;
|
||||
|
||||
MultiPageInfo({
|
||||
@required this.mainEntry,
|
||||
List<SinglePageInfo> pages,
|
||||
required this.mainEntry,
|
||||
required List<SinglePageInfo> pages,
|
||||
}) : _pages = pages {
|
||||
if (_pages.isNotEmpty) {
|
||||
_pages.sort();
|
||||
|
@ -31,15 +32,15 @@ class MultiPageInfo {
|
|||
);
|
||||
}
|
||||
|
||||
SinglePageInfo get defaultPage => _pages.firstWhere((page) => page.isDefault, orElse: () => null);
|
||||
SinglePageInfo? get defaultPage => _pages.firstWhereOrNull((page) => page.isDefault);
|
||||
|
||||
SinglePageInfo getById(int pageId) => _pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
|
||||
SinglePageInfo? getById(int? pageId) => _pages.firstWhereOrNull((page) => page.pageId == pageId);
|
||||
|
||||
SinglePageInfo getByIndex(int pageIndex) => _pages.firstWhere((page) => page.index == pageIndex, orElse: () => null);
|
||||
SinglePageInfo? getByIndex(int? pageIndex) => _pages.firstWhereOrNull((page) => page.index == pageIndex);
|
||||
|
||||
AvesEntry getPageEntryByIndex(int pageIndex) => _getPageEntry(getByIndex(pageIndex));
|
||||
AvesEntry getPageEntryByIndex(int? pageIndex) => _getPageEntry(getByIndex(pageIndex));
|
||||
|
||||
AvesEntry _getPageEntry(SinglePageInfo pageInfo) {
|
||||
AvesEntry _getPageEntry(SinglePageInfo? pageInfo) {
|
||||
if (pageInfo != null) {
|
||||
return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo));
|
||||
} else {
|
||||
|
@ -52,20 +53,20 @@ class MultiPageInfo {
|
|||
List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList();
|
||||
|
||||
Future<void> extractMotionPhotoVideo() async {
|
||||
final videoPage = _pages.firstWhere((page) => page.isVideo, orElse: () => null);
|
||||
final videoPage = _pages.firstWhereOrNull((page) => page.isVideo);
|
||||
if (videoPage != null && videoPage.uri == null) {
|
||||
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
|
||||
if (fields != null) {
|
||||
if (fields.containsKey('uri')) {
|
||||
final pageIndex = _pages.indexOf(videoPage);
|
||||
_pages.removeAt(pageIndex);
|
||||
_pages.insert(
|
||||
pageIndex,
|
||||
videoPage.copyWith(
|
||||
uri: fields['uri'] as String,
|
||||
uri: fields['uri'] as String?,
|
||||
// the initial fake page may contain inaccurate values for the following fields
|
||||
// so we override them with values from the extracted standalone video
|
||||
rotationDegrees: fields['sourceRotationDegrees'] as int,
|
||||
durationMillis: fields['durationMillis'] as int,
|
||||
rotationDegrees: fields['sourceRotationDegrees'] as int?,
|
||||
durationMillis: fields['durationMillis'] as int?,
|
||||
));
|
||||
_pageEntries.remove(videoPage);
|
||||
}
|
||||
|
@ -83,9 +84,9 @@ class MultiPageInfo {
|
|||
path: mainEntry.path,
|
||||
contentId: mainEntry.contentId,
|
||||
pageId: pageId,
|
||||
sourceMimeType: pageInfo.mimeType ?? mainEntry.sourceMimeType,
|
||||
width: pageInfo.width ?? mainEntry.width,
|
||||
height: pageInfo.height ?? mainEntry.height,
|
||||
sourceMimeType: pageInfo.mimeType,
|
||||
width: pageInfo.width,
|
||||
height: pageInfo.height,
|
||||
sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees,
|
||||
sizeBytes: mainEntry.sizeBytes,
|
||||
sourceTitle: mainEntry.sourceTitle,
|
||||
|
@ -108,26 +109,28 @@ class MultiPageInfo {
|
|||
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||
final int index, pageId;
|
||||
final bool isDefault;
|
||||
final String uri, mimeType;
|
||||
final int width, height, rotationDegrees, durationMillis;
|
||||
final String? uri;
|
||||
final String mimeType;
|
||||
final int width, height;
|
||||
final int? rotationDegrees, durationMillis;
|
||||
|
||||
const SinglePageInfo({
|
||||
this.index,
|
||||
this.pageId,
|
||||
this.isDefault,
|
||||
required this.index,
|
||||
required this.pageId,
|
||||
required this.isDefault,
|
||||
this.uri,
|
||||
this.mimeType,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.mimeType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.rotationDegrees,
|
||||
this.durationMillis,
|
||||
});
|
||||
|
||||
SinglePageInfo copyWith({
|
||||
bool isDefault,
|
||||
String uri,
|
||||
int rotationDegrees,
|
||||
int durationMillis,
|
||||
bool? isDefault,
|
||||
String? uri,
|
||||
int? rotationDegrees,
|
||||
int? durationMillis,
|
||||
}) {
|
||||
return SinglePageInfo(
|
||||
index: index,
|
||||
|
@ -147,12 +150,12 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
|
|||
return SinglePageInfo(
|
||||
index: index,
|
||||
pageId: index,
|
||||
isDefault: map['isDefault'] as bool ?? false,
|
||||
isDefault: map['isDefault'] as bool? ?? false,
|
||||
mimeType: map['mimeType'] as String,
|
||||
width: map['width'] as int ?? 0,
|
||||
height: map['height'] as int ?? 0,
|
||||
rotationDegrees: map['rotationDegrees'] as int,
|
||||
durationMillis: map['durationMillis'] as int,
|
||||
width: map['width'] as int? ?? 0,
|
||||
height: map['height'] as int? ?? 0,
|
||||
rotationDegrees: map['rotationDegrees'] as int?,
|
||||
durationMillis: map['durationMillis'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class PanoramaInfo {
|
||||
final Rect croppedAreaRect;
|
||||
final Size fullPanoSize;
|
||||
final String projectionType;
|
||||
final Rect? croppedAreaRect;
|
||||
final Size? fullPanoSize;
|
||||
final String? projectionType;
|
||||
|
||||
PanoramaInfo({
|
||||
this.croppedAreaRect,
|
||||
|
@ -13,13 +13,13 @@ class PanoramaInfo {
|
|||
});
|
||||
|
||||
factory PanoramaInfo.fromMap(Map map) {
|
||||
var cLeft = map['croppedAreaLeft'] as int;
|
||||
var cTop = map['croppedAreaTop'] as int;
|
||||
final cWidth = map['croppedAreaWidth'] as int;
|
||||
final cHeight = map['croppedAreaHeight'] as int;
|
||||
var fWidth = map['fullPanoWidth'] as int;
|
||||
var fHeight = map['fullPanoHeight'] as int;
|
||||
final projectionType = map['projectionType'] as String;
|
||||
var cLeft = map['croppedAreaLeft'] as int?;
|
||||
var cTop = map['croppedAreaTop'] as int?;
|
||||
final cWidth = map['croppedAreaWidth'] as int?;
|
||||
final cHeight = map['croppedAreaHeight'] as int?;
|
||||
var fWidth = map['fullPanoWidth'] as int?;
|
||||
var fHeight = map['fullPanoHeight'] as int?;
|
||||
final projectionType = map['projectionType'] as String?;
|
||||
|
||||
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
|
||||
if (fHeight == null && cWidth != null && cHeight != null) {
|
||||
|
@ -31,12 +31,12 @@ class PanoramaInfo {
|
|||
cLeft = 0;
|
||||
}
|
||||
|
||||
Rect croppedAreaRect;
|
||||
Rect? croppedAreaRect;
|
||||
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
|
||||
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
|
||||
}
|
||||
|
||||
Size fullPanoSize;
|
||||
Size? fullPanoSize;
|
||||
if (fWidth != null && fHeight != null) {
|
||||
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
|
||||
}
|
||||
|
|
|
@ -15,12 +15,11 @@ extension ExtraEntryBackground on EntryBackground {
|
|||
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case EntryBackground.black:
|
||||
return Colors.black;
|
||||
case EntryBackground.white:
|
||||
return Colors.white;
|
||||
case EntryBackground.black:
|
||||
default:
|
||||
return null;
|
||||
return Colors.black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
@ -14,7 +15,7 @@ import 'enums.dart';
|
|||
final Settings settings = Settings._private();
|
||||
|
||||
class Settings extends ChangeNotifier {
|
||||
static SharedPreferences _prefs;
|
||||
static SharedPreferences? /*late final*/ _prefs;
|
||||
|
||||
Settings._private();
|
||||
|
||||
|
@ -93,7 +94,7 @@ class Settings extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> reset() {
|
||||
return _prefs.clear();
|
||||
return _prefs!.clear();
|
||||
}
|
||||
|
||||
// app
|
||||
|
@ -111,7 +112,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
static const localeSeparator = '-';
|
||||
|
||||
Locale get locale {
|
||||
Locale? get locale {
|
||||
// exceptionally allow getting locale before settings are initialized
|
||||
final tag = _prefs?.getString(localeKey);
|
||||
if (tag != null) {
|
||||
|
@ -125,11 +126,11 @@ class Settings extends ChangeNotifier {
|
|||
return null;
|
||||
}
|
||||
|
||||
set locale(Locale newValue) {
|
||||
String tag;
|
||||
set locale(Locale? newValue) {
|
||||
String? tag;
|
||||
if (newValue != null) {
|
||||
tag = [
|
||||
newValue.languageCode ?? '',
|
||||
newValue.languageCode,
|
||||
newValue.scriptCode ?? '',
|
||||
newValue.countryCode ?? '',
|
||||
].join(localeSeparator);
|
||||
|
@ -152,11 +153,11 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
|
||||
|
||||
String get catalogTimeZone => _prefs.getString(catalogTimeZoneKey) ?? '';
|
||||
String get catalogTimeZone => _prefs!.getString(catalogTimeZoneKey) ?? '';
|
||||
|
||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
||||
|
||||
double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
double getTileExtent(String routeName) => _prefs!.getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
|
||||
// do not notify, as tile extents are only used internally by `TileExtentController`
|
||||
// and should not trigger rebuilding by change notification
|
||||
|
@ -202,11 +203,11 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString());
|
||||
|
||||
Set<CollectionFilter> get pinnedFilters => (_prefs.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
|
||||
Set<CollectionFilter> get pinnedFilters => (_prefs!.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).where((v) => v != null).cast<CollectionFilter>().toSet();
|
||||
|
||||
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
Set<CollectionFilter> get hiddenFilters => (_prefs.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
|
||||
Set<CollectionFilter> get hiddenFilters => (_prefs!.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).where((v) => v != null).cast<CollectionFilter>().toSet();
|
||||
|
||||
set hiddenFilters(Set<CollectionFilter> newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
|
@ -248,7 +249,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString());
|
||||
|
||||
double get infoMapZoom => _prefs.getDouble(infoMapZoomKey) ?? 12;
|
||||
double get infoMapZoom => _prefs!.getDouble(infoMapZoomKey) ?? 12;
|
||||
|
||||
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
|
||||
|
||||
|
@ -272,23 +273,23 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set saveSearchHistory(bool newValue) => setAndNotify(saveSearchHistoryKey, newValue);
|
||||
|
||||
List<CollectionFilter> get searchHistory => (_prefs.getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).toList();
|
||||
List<CollectionFilter> get searchHistory => (_prefs!.getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).where((v) => v != null).cast<CollectionFilter>().toList();
|
||||
|
||||
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
// version
|
||||
|
||||
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs.getInt(lastVersionCheckDateKey) ?? 0);
|
||||
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs!.getInt(lastVersionCheckDateKey) ?? 0);
|
||||
|
||||
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
|
||||
|
||||
// convenience methods
|
||||
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
bool getBoolOrDefault(String key, bool defaultValue) => _prefs.getKeys().contains(key) ? _prefs.getBool(key) : defaultValue;
|
||||
bool getBoolOrDefault(String key, bool defaultValue) => _prefs!.getBool(key) ?? defaultValue;
|
||||
|
||||
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
|
||||
final valueString = _prefs.getString(key);
|
||||
final valueString = _prefs!.getString(key);
|
||||
for (final v in values) {
|
||||
if (v.toString() == valueString) {
|
||||
return v;
|
||||
|
@ -298,28 +299,28 @@ class Settings extends ChangeNotifier {
|
|||
}
|
||||
|
||||
List<T> getEnumListOrDefault<T>(String key, List<T> defaultValue, Iterable<T> values) {
|
||||
return _prefs.getStringList(key)?.map((s) => values.firstWhere((v) => v.toString() == s, orElse: () => null))?.where((v) => v != null)?.toList() ?? defaultValue;
|
||||
return _prefs!.getStringList(key)?.map((s) => values.firstWhereOrNull((v) => v.toString() == s)).where((v) => v != null).cast<T>().toList() ?? defaultValue;
|
||||
}
|
||||
|
||||
void setAndNotify(String key, dynamic newValue, {bool notify = true}) {
|
||||
var oldValue = _prefs.get(key);
|
||||
var oldValue = _prefs!.get(key);
|
||||
if (newValue == null) {
|
||||
_prefs.remove(key);
|
||||
_prefs!.remove(key);
|
||||
} else if (newValue is String) {
|
||||
oldValue = _prefs.getString(key);
|
||||
_prefs.setString(key, newValue);
|
||||
oldValue = _prefs!.getString(key);
|
||||
_prefs!.setString(key, newValue);
|
||||
} else if (newValue is List<String>) {
|
||||
oldValue = _prefs.getStringList(key);
|
||||
_prefs.setStringList(key, newValue);
|
||||
oldValue = _prefs!.getStringList(key);
|
||||
_prefs!.setStringList(key, newValue);
|
||||
} else if (newValue is int) {
|
||||
oldValue = _prefs.getInt(key);
|
||||
_prefs.setInt(key, newValue);
|
||||
oldValue = _prefs!.getInt(key);
|
||||
_prefs!.setInt(key, newValue);
|
||||
} else if (newValue is double) {
|
||||
oldValue = _prefs.getDouble(key);
|
||||
_prefs.setDouble(key, newValue);
|
||||
oldValue = _prefs!.getDouble(key);
|
||||
_prefs!.setDouble(key, newValue);
|
||||
} else if (newValue is bool) {
|
||||
oldValue = _prefs.getBool(key);
|
||||
_prefs.setBool(key, newValue);
|
||||
oldValue = _prefs!.getBool(key);
|
||||
_prefs!.setBool(key, newValue);
|
||||
}
|
||||
if (oldValue != newValue && notify) {
|
||||
notifyListeners();
|
||||
|
|
|
@ -25,11 +25,10 @@ extension ExtraVideoLoopMode on VideoLoopMode {
|
|||
case VideoLoopMode.never:
|
||||
return false;
|
||||
case VideoLoopMode.shortOnly:
|
||||
if (entry.durationMillis == null) return false;
|
||||
return entry.durationMillis < shortVideoThreshold.inMilliseconds;
|
||||
final durationMillis = entry.durationMillis;
|
||||
return durationMillis != null ? durationMillis < shortVideoThreshold.inMilliseconds : false;
|
||||
case VideoLoopMode.always:
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
mixin AlbumMixin on SourceBase {
|
||||
final Set<String> _directories = {};
|
||||
final Set<String?> _directories = {};
|
||||
|
||||
List<String> get rawAlbums => List.unmodifiable(_directories);
|
||||
|
||||
|
@ -25,7 +25,7 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getAlbumDisplayName(BuildContext context, String dirPath) {
|
||||
String getAlbumDisplayName(BuildContext? context, String dirPath) {
|
||||
assert(!dirPath.endsWith(pContext.separator));
|
||||
|
||||
if (context != null) {
|
||||
|
@ -41,15 +41,15 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
return volume.getDescription(context);
|
||||
}
|
||||
|
||||
String unique(String dirPath, Set<String> others) {
|
||||
String unique(String dirPath, Set<String?> others) {
|
||||
final parts = pContext.split(dirPath);
|
||||
for (var i = parts.length - 1; i > 0; i--) {
|
||||
final testName = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
if (others.every((item) => !item.endsWith(testName))) return testName;
|
||||
if (others.every((item) => !item!.endsWith(testName))) return testName;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
@ -61,10 +61,10 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
final volumePath = dir.volumePath;
|
||||
String trimVolumePath(String path) => path.substring(dir.volumePath.length);
|
||||
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
|
||||
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
|
@ -72,7 +72,7 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
}
|
||||
|
||||
Map<String, AvesEntry> getAlbumEntries() {
|
||||
Map<String, AvesEntry?> getAlbumEntries() {
|
||||
final entries = sortedEntriesByDate;
|
||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (final album in rawAlbums) {
|
||||
|
@ -90,7 +90,7 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) => MapEntry(
|
||||
album,
|
||||
entries.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
entries.firstWhereOrNull((entry) => entry.directory == album),
|
||||
)));
|
||||
}
|
||||
|
||||
|
@ -100,14 +100,14 @@ mixin AlbumMixin on SourceBase {
|
|||
cleanEmptyAlbums();
|
||||
}
|
||||
|
||||
void addDirectories(Set<String> albums) {
|
||||
void addDirectories(Set<String?> albums) {
|
||||
if (!_directories.containsAll(albums)) {
|
||||
_directories.addAll(albums);
|
||||
_notifyAlbumChange();
|
||||
}
|
||||
}
|
||||
|
||||
void cleanEmptyAlbums([Set<String> albums]) {
|
||||
void cleanEmptyAlbums([Set<String?>? albums]) {
|
||||
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
|
||||
if (emptyAlbums.isNotEmpty) {
|
||||
_directories.removeAll(emptyAlbums);
|
||||
|
@ -120,20 +120,20 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
}
|
||||
|
||||
bool _isEmptyAlbum(String album) => !visibleEntries.any((entry) => entry.directory == album);
|
||||
bool _isEmptyAlbum(String? album) => !visibleEntries.any((entry) => entry.directory == album);
|
||||
|
||||
// filter summary
|
||||
|
||||
// by directory
|
||||
final Map<String, int> _filterEntryCountMap = {};
|
||||
final Map<String, AvesEntry> _filterRecentEntryMap = {};
|
||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateAlbumFilterSummary({Set<AvesEntry> entries, Set<String> directories}) {
|
||||
void invalidateAlbumFilterSummary({Set<AvesEntry>? entries, Set<String?>? directories}) {
|
||||
if (entries == null && directories == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} else {
|
||||
directories ??= entries.map((entry) => entry.directory).toSet();
|
||||
directories ??= entries!.map((entry) => entry.directory).toSet();
|
||||
directories.forEach(_filterEntryCountMap.remove);
|
||||
directories.forEach(_filterRecentEntryMap.remove);
|
||||
}
|
||||
|
@ -144,15 +144,15 @@ mixin AlbumMixin on SourceBase {
|
|||
return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length);
|
||||
}
|
||||
|
||||
AvesEntry albumRecentEntry(AlbumFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
|
||||
AvesEntry? albumRecentEntry(AlbumFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumsChangedEvent {}
|
||||
|
||||
class AlbumSummaryInvalidatedEvent {
|
||||
final Set<String> directories;
|
||||
final Set<String?>? directories;
|
||||
|
||||
const AlbumSummaryInvalidatedEvent(this.directories);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,9 @@ import 'dart:async';
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -22,22 +24,22 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
EntryGroupFactor groupFactor;
|
||||
EntrySortFactor sortFactor;
|
||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
|
||||
int id;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource;
|
||||
|
||||
List<AvesEntry> _filteredSortedEntries;
|
||||
List<StreamSubscription> _subscriptions = [];
|
||||
List<AvesEntry> _filteredSortedEntries = [];
|
||||
|
||||
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
||||
|
||||
CollectionLens({
|
||||
@required this.source,
|
||||
Iterable<CollectionFilter> filters,
|
||||
EntryGroupFactor groupFactor,
|
||||
EntrySortFactor sortFactor,
|
||||
required this.source,
|
||||
Iterable<CollectionFilter?>? filters,
|
||||
EntryGroupFactor? groupFactor,
|
||||
EntrySortFactor? sortFactor,
|
||||
this.id,
|
||||
this.listenToSource = true,
|
||||
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
||||
}) : filters = (filters ?? {}).where((f) => f != null).cast<CollectionFilter>().toSet(),
|
||||
groupFactor = groupFactor ?? settings.collectionGroupFactor,
|
||||
sortFactor = sortFactor ?? settings.collectionSortFactor {
|
||||
id ??= hashCode;
|
||||
|
@ -52,6 +54,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_refresh();
|
||||
}
|
||||
}));
|
||||
favourites.addListener(onFavouritesChanged);
|
||||
}
|
||||
_refresh();
|
||||
}
|
||||
|
@ -61,7 +64,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
_subscriptions = null;
|
||||
favourites.removeListener(onFavouritesChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -70,11 +73,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
int get entryCount => _filteredSortedEntries.length;
|
||||
|
||||
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
||||
List<AvesEntry> _sortedEntries;
|
||||
List<AvesEntry>? _sortedEntries;
|
||||
|
||||
List<AvesEntry> get sortedEntries {
|
||||
_sortedEntries ??= List.of(sections.entries.expand((e) => e.value));
|
||||
return _sortedEntries;
|
||||
_sortedEntries ??= List.of(sections.entries.expand((kv) => kv.value));
|
||||
return _sortedEntries!;
|
||||
}
|
||||
|
||||
bool get showHeaders {
|
||||
|
@ -90,7 +93,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
}
|
||||
|
||||
void addFilter(CollectionFilter filter) {
|
||||
if (filter == null || filters.contains(filter)) return;
|
||||
if (filters.contains(filter)) return;
|
||||
if (filter.isUnique) {
|
||||
filters.removeWhere((old) => old.category == filter.category);
|
||||
}
|
||||
|
@ -99,7 +102,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
}
|
||||
|
||||
void removeFilter(CollectionFilter filter) {
|
||||
if (filter == null || !filters.contains(filter)) return;
|
||||
if (!filters.contains(filter)) return;
|
||||
filters.remove(filter);
|
||||
onFilterChanged();
|
||||
}
|
||||
|
@ -156,19 +159,19 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
break;
|
||||
case EntryGroupFactor.none:
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(null, _filteredSortedEntries),
|
||||
MapEntry(const SectionKey(), _filteredSortedEntries),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(null, _filteredSortedEntries),
|
||||
MapEntry(const SectionKey(), _filteredSortedEntries),
|
||||
]);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!));
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
@ -184,7 +187,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_applyGroup();
|
||||
}
|
||||
|
||||
void onEntryAdded(Set<AvesEntry> entries) {
|
||||
void onFavouritesChanged() {
|
||||
if (filters.any((filter) => filter is FavouriteFilter)) {
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void onEntryAdded(Set<AvesEntry>? entries) {
|
||||
_refresh();
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import 'package:aves/model/source/location.dart';
|
|||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
@ -31,7 +32,7 @@ mixin SourceBase {
|
|||
|
||||
Stream<ProgressEvent> get progressStream => _progressStreamController.stream;
|
||||
|
||||
void setProgress({@required int done, @required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
|
||||
void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
|
||||
}
|
||||
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||
|
@ -45,24 +46,24 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
// TODO TLAD use `Set.unmodifiable()` when possible
|
||||
Set<AvesEntry> get allEntries => Set.of(_rawEntries);
|
||||
|
||||
Set<AvesEntry> _visibleEntries;
|
||||
Set<AvesEntry>? _visibleEntries;
|
||||
|
||||
@override
|
||||
Set<AvesEntry> get visibleEntries {
|
||||
// TODO TLAD use `Set.unmodifiable()` when possible
|
||||
_visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries));
|
||||
return _visibleEntries;
|
||||
return _visibleEntries!;
|
||||
}
|
||||
|
||||
List<AvesEntry> _sortedEntriesByDate;
|
||||
List<AvesEntry>? _sortedEntriesByDate;
|
||||
|
||||
@override
|
||||
List<AvesEntry> get sortedEntriesByDate {
|
||||
_sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate));
|
||||
return _sortedEntriesByDate;
|
||||
return _sortedEntriesByDate!;
|
||||
}
|
||||
|
||||
List<DateMetadata> _savedDates;
|
||||
late List<DateMetadata> _savedDates;
|
||||
|
||||
Future<void> loadDates() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -75,7 +76,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
||||
}
|
||||
|
||||
void _invalidate([Set<AvesEntry> entries]) {
|
||||
void _invalidate([Set<AvesEntry>? entries]) {
|
||||
_visibleEntries = null;
|
||||
_sortedEntriesByDate = null;
|
||||
invalidateAlbumFilterSummary(entries: entries);
|
||||
|
@ -91,7 +92,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
entries.forEach((entry) {
|
||||
final contentId = entry.contentId;
|
||||
entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
|
||||
entry.catalogDateMillis = _savedDates.firstWhereOrNull((metadata) => metadata.contentId == contentId)?.dateMillis;
|
||||
});
|
||||
_rawEntries.addAll(entries);
|
||||
_invalidate(entries);
|
||||
|
@ -124,16 +125,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
|
||||
final oldContentId = entry.contentId;
|
||||
final newContentId = newFields['contentId'] as int;
|
||||
final oldContentId = entry.contentId!;
|
||||
final newContentId = newFields['contentId'] as int?;
|
||||
|
||||
entry.contentId = newContentId;
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||
if (newFields.containsKey('path')) entry.path = newFields['path'] as String;
|
||||
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
|
||||
if (newFields.containsKey('path')) entry.path = newFields['path'] as String?;
|
||||
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
|
||||
if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String;
|
||||
if (newFields.containsKey('title')) entry.sourceTitle = newFields['title'] as String?;
|
||||
|
||||
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
|
||||
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
|
||||
|
@ -152,6 +153,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
await _moveEntry(entry, newFields);
|
||||
entry.metadataChangeNotifier.notifyListeners();
|
||||
eventBus.fire(EntryMovedEvent({entry}));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -159,7 +161,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final oldFilter = AlbumFilter(sourceAlbum, null);
|
||||
final pinned = settings.pinnedFilters.contains(oldFilter);
|
||||
final oldCoverContentId = covers.coverContentId(oldFilter);
|
||||
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhere((entry) => entry.contentId == oldCoverContentId, orElse: () => null) : null;
|
||||
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
|
||||
await updateAfterMove(
|
||||
todoEntries: todoEntries,
|
||||
copy: false,
|
||||
|
@ -177,37 +179,39 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
Future<void> updateAfterMove({
|
||||
@required Set<AvesEntry> todoEntries,
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
@required Set<MoveOpEvent> movedOps,
|
||||
required Set<AvesEntry> todoEntries,
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
required Set<MoveOpEvent> movedOps,
|
||||
}) async {
|
||||
if (movedOps.isEmpty) return;
|
||||
|
||||
final fromAlbums = <String>{};
|
||||
final fromAlbums = <String?>{};
|
||||
final movedEntries = <AvesEntry>{};
|
||||
if (copy) {
|
||||
movedOps.forEach((movedOp) {
|
||||
final sourceUri = movedOp.uri;
|
||||
final newFields = movedOp.newFields;
|
||||
final sourceEntry = todoEntries.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
|
||||
fromAlbums.add(sourceEntry.directory);
|
||||
movedEntries.add(sourceEntry?.copyWith(
|
||||
uri: newFields['uri'] as String,
|
||||
path: newFields['path'] as String,
|
||||
contentId: newFields['contentId'] as int,
|
||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
|
||||
));
|
||||
final sourceEntry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
||||
if (sourceEntry != null) {
|
||||
fromAlbums.add(sourceEntry.directory);
|
||||
movedEntries.add(sourceEntry.copyWith(
|
||||
uri: newFields['uri'] as String?,
|
||||
path: newFields['path'] as String?,
|
||||
contentId: newFields['contentId'] as int?,
|
||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
||||
));
|
||||
}
|
||||
});
|
||||
await metadataDb.saveEntries(movedEntries);
|
||||
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
|
||||
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
|
||||
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata).where((v) => v != null).cast<CatalogMetadata>().toSet());
|
||||
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).where((v) => v != null).cast<AddressDetails>().toSet());
|
||||
} else {
|
||||
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
|
||||
final newFields = movedOp.newFields;
|
||||
if (newFields.isNotEmpty) {
|
||||
final sourceUri = movedOp.uri;
|
||||
final entry = todoEntries.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
|
||||
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
||||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
movedEntries.add(entry);
|
||||
|
@ -255,17 +259,17 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return 0;
|
||||
}
|
||||
|
||||
AvesEntry recentEntry(CollectionFilter filter) {
|
||||
AvesEntry? recentEntry(CollectionFilter filter) {
|
||||
if (filter is AlbumFilter) return albumRecentEntry(filter);
|
||||
if (filter is LocationFilter) return countryRecentEntry(filter);
|
||||
if (filter is TagFilter) return tagRecentEntry(filter);
|
||||
return null;
|
||||
}
|
||||
|
||||
AvesEntry coverEntry(CollectionFilter filter) {
|
||||
AvesEntry? coverEntry(CollectionFilter filter) {
|
||||
final contentId = covers.coverContentId(filter);
|
||||
if (contentId != null) {
|
||||
final entry = visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||
final entry = visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
|
||||
if (entry != null) return entry;
|
||||
}
|
||||
return recentEntry(filter);
|
||||
|
@ -297,7 +301,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
class EntryAddedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
final Set<AvesEntry>? entries;
|
||||
|
||||
const EntryAddedEvent([this.entries]);
|
||||
}
|
||||
|
@ -309,7 +313,7 @@ class EntryRemovedEvent {
|
|||
}
|
||||
|
||||
class EntryMovedEvent {
|
||||
final Iterable<AvesEntry> entries;
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
@ -324,5 +328,5 @@ class FilterVisibilityChangedEvent {
|
|||
class ProgressEvent {
|
||||
final int done, total;
|
||||
|
||||
const ProgressEvent({@required this.done, @required this.total});
|
||||
const ProgressEvent({required this.done, required this.total});
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ mixin LocationMixin on SourceBase {
|
|||
final saved = await metadataDb.loadAddresses();
|
||||
visibleEntries.forEach((entry) {
|
||||
final contentId = entry.contentId;
|
||||
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
|
||||
entry.addressDetails = saved.firstWhereOrNull((address) => address.contentId == contentId);
|
||||
});
|
||||
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onAddressMetadataChanged();
|
||||
|
@ -44,19 +44,19 @@ mixin LocationMixin on SourceBase {
|
|||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng).toSet());
|
||||
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
|
||||
final newAddresses = <AddressDetails>[];
|
||||
todo.forEach((entry) {
|
||||
final position = entry.latLng;
|
||||
final countryCode = countryCodeMap.entries.firstWhere((kv) => kv.value.contains(position), orElse: () => null)?.key;
|
||||
final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key;
|
||||
entry.setCountry(countryCode);
|
||||
if (entry.hasAddress) {
|
||||
newAddresses.add(entry.addressDetails);
|
||||
newAddresses.add(entry.addressDetails!);
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
|
@ -82,14 +82,16 @@ mixin LocationMixin on SourceBase {
|
|||
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
|
||||
final latLngFactor = pow(10, 2);
|
||||
Tuple2<int, int> approximateLatLng(AvesEntry entry) {
|
||||
final lat = entry.catalogMetadata?.latitude;
|
||||
final lng = entry.catalogMetadata?.longitude;
|
||||
if (lat == null || lng == null) return null;
|
||||
// entry has coordinates
|
||||
final lat = entry.catalogMetadata!.latitude!;
|
||||
final lng = entry.catalogMetadata!.longitude!;
|
||||
return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
|
||||
}
|
||||
|
||||
final knownLocations = <Tuple2<int, int>, AddressDetails>{};
|
||||
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
|
||||
final knownLocations = <Tuple2<int, int>, AddressDetails?>{};
|
||||
byLocated[true]?.forEach((entry) {
|
||||
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
|
||||
});
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
var progressDone = 0;
|
||||
|
@ -108,9 +110,9 @@ mixin LocationMixin on SourceBase {
|
|||
knownLocations[latLng] = entry.addressDetails;
|
||||
}
|
||||
if (entry.hasFineAddress) {
|
||||
newAddresses.add(entry.addressDetails);
|
||||
newAddresses.add(entry.addressDetails!);
|
||||
if (newAddresses.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
newAddresses.clear();
|
||||
}
|
||||
|
@ -118,7 +120,7 @@ mixin LocationMixin on SourceBase {
|
|||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
|
@ -130,8 +132,8 @@ mixin LocationMixin on SourceBase {
|
|||
}
|
||||
|
||||
void updateLocations() {
|
||||
final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).toList();
|
||||
final updatedPlaces = locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).cast<AddressDetails>().toList();
|
||||
final updatedPlaces = locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase as int Function(String?, String?)?);
|
||||
if (!listEquals(updatedPlaces, sortedPlaces)) {
|
||||
sortedPlaces = List.unmodifiable(updatedPlaces);
|
||||
eventBus.fire(PlacesChangedEvent());
|
||||
|
@ -140,7 +142,7 @@ mixin LocationMixin on SourceBase {
|
|||
// the same country code could be found with different country names
|
||||
// e.g. if the locale changed between geocoding calls
|
||||
// so we merge countries by code, keeping only one name for each code
|
||||
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
|
||||
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key!.isNotEmpty));
|
||||
final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase);
|
||||
if (!listEquals(updatedCountries, sortedCountries)) {
|
||||
sortedCountries = List.unmodifiable(updatedCountries);
|
||||
|
@ -153,27 +155,30 @@ mixin LocationMixin on SourceBase {
|
|||
|
||||
// by country code
|
||||
final Map<String, int> _filterEntryCountMap = {};
|
||||
final Map<String, AvesEntry> _filterRecentEntryMap = {};
|
||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
|
||||
Set<String> countryCodes;
|
||||
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
|
||||
Set<String>? countryCodes;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} else {
|
||||
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails.countryCode).toSet();
|
||||
countryCodes.remove(null);
|
||||
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails!.countryCode).where((v) => v != null).cast<String>().toSet();
|
||||
countryCodes.forEach(_filterEntryCountMap.remove);
|
||||
}
|
||||
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
|
||||
}
|
||||
|
||||
int countryEntryCount(LocationFilter filter) {
|
||||
return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where(filter.test).length);
|
||||
final countryCode = filter.countryCode;
|
||||
if (countryCode == null) return 0;
|
||||
return _filterEntryCountMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).length);
|
||||
}
|
||||
|
||||
AvesEntry countryRecentEntry(LocationFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
|
||||
AvesEntry? countryRecentEntry(LocationFilter filter) {
|
||||
final countryCode = filter.countryCode;
|
||||
if (countryCode == null) return null;
|
||||
return _filterRecentEntryMap.putIfAbsent(countryCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -184,7 +189,7 @@ class PlacesChangedEvent {}
|
|||
class CountriesChangedEvent {}
|
||||
|
||||
class CountrySummaryInvalidatedEvent {
|
||||
final Set<String> countryCodes;
|
||||
final Set<String>? countryCodes;
|
||||
|
||||
const CountrySummaryInvalidatedEvent(this.countryCodes);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -27,13 +28,15 @@ class MediaStoreSource extends CollectionSource {
|
|||
await favourites.init();
|
||||
await covers.init();
|
||||
final currentTimeZone = await timeService.getDefaultTimeZone();
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
debugPrint('$runtimeType clear catalog metadata to get correct date/times');
|
||||
await metadataDb.clearDates();
|
||||
await metadataDb.clearMetadataEntries();
|
||||
settings.catalogTimeZone = currentTimeZone;
|
||||
if (currentTimeZone != null) {
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
debugPrint('$runtimeType clear catalog metadata to get correct date/times');
|
||||
await metadataDb.clearDates();
|
||||
await metadataDb.clearMetadataEntries();
|
||||
settings.catalogTimeZone = currentTimeZone;
|
||||
}
|
||||
}
|
||||
await loadDates(); // 100ms for 5400 entries
|
||||
_initialized = true;
|
||||
|
@ -49,7 +52,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
clearEntries();
|
||||
|
||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||
|
||||
|
@ -63,7 +66,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
|
||||
|
||||
// verify paths because some apps move files without updating their `last modified date`
|
||||
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
|
||||
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path)));
|
||||
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
|
||||
movedContentIds.forEach((contentId) {
|
||||
// make obsolete by resetting its modified date
|
||||
|
@ -130,20 +133,22 @@ class MediaStoreSource extends CollectionSource {
|
|||
Future<Set<String>> refreshUris(Set<String> changedUris) async {
|
||||
if (!_initialized || !isMonitoring) return changedUris;
|
||||
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
if (uri == null) return null;
|
||||
final pathSegments = Uri.parse(uri).pathSegments;
|
||||
// e.g. URI `content://media/` has no path segment
|
||||
if (pathSegments.isEmpty) return null;
|
||||
final idString = pathSegments.last;
|
||||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
}).where((kv) => kv != null));
|
||||
final uriByContentId = Map.fromEntries(changedUris
|
||||
.map((uri) {
|
||||
final pathSegments = Uri.parse(uri).pathSegments;
|
||||
// e.g. URI `content://media/` has no path segment
|
||||
if (pathSegments.isEmpty) return null;
|
||||
final idString = pathSegments.last;
|
||||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
})
|
||||
.where((kv) => kv != null)
|
||||
.cast<MapEntry<int, String>>());
|
||||
|
||||
// clean up obsolete entries
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
|
||||
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
|
||||
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).where((v) => v != null).cast<String>().toSet();
|
||||
await removeEntries(obsoleteUris);
|
||||
obsoleteContentIds.forEach(uriByContentId.remove);
|
||||
|
||||
|
@ -156,14 +161,16 @@ class MediaStoreSource extends CollectionSource {
|
|||
final uri = kv.value;
|
||||
final sourceEntry = await imageFileService.getEntry(uri, null);
|
||||
if (sourceEntry != null) {
|
||||
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
|
||||
// compare paths because some apps move files without updating their `last modified date`
|
||||
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs || sourceEntry.path != existingEntry.path) {
|
||||
final volume = androidFileUtils.getStorageVolume(sourceEntry.path);
|
||||
if (existingEntry == null || (sourceEntry.dateModifiedSecs ?? 0) > (existingEntry.dateModifiedSecs ?? 0) || sourceEntry.path != existingEntry.path) {
|
||||
final newPath = sourceEntry.path;
|
||||
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
|
||||
if (volume != null) {
|
||||
newEntries.add(sourceEntry);
|
||||
if (existingEntry != null) {
|
||||
existingDirectories.add(existingEntry.directory);
|
||||
final existingDirectory = existingEntry?.directory;
|
||||
if (existingDirectory != null) {
|
||||
existingDirectories.add(existingDirectory);
|
||||
}
|
||||
} else {
|
||||
debugPrint('$runtimeType refreshUris entry=$sourceEntry is not located on a known storage volume. Will retry soon...');
|
||||
|
@ -189,7 +196,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
@override
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
metadataDb.removeIds(contentIds, metadataOnly: true);
|
||||
metadataDb.removeIds(contentIds as Set<int>, metadataOnly: true);
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ class SectionKey {
|
|||
}
|
||||
|
||||
class EntryAlbumSectionKey extends SectionKey {
|
||||
final String directory;
|
||||
final String? directory;
|
||||
|
||||
const EntryAlbumSectionKey(this.directory);
|
||||
|
||||
|
@ -23,7 +23,7 @@ class EntryAlbumSectionKey extends SectionKey {
|
|||
}
|
||||
|
||||
class EntryDateSectionKey extends SectionKey {
|
||||
final DateTime date;
|
||||
final DateTime? date;
|
||||
|
||||
const EntryDateSectionKey(this.date);
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ mixin TagMixin on SourceBase {
|
|||
final saved = await metadataDb.loadMetadataEntries();
|
||||
visibleEntries.forEach((entry) {
|
||||
final contentId = entry.contentId;
|
||||
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
|
||||
entry.catalogMetadata = saved.firstWhereOrNull((metadata) => metadata.contentId == contentId);
|
||||
});
|
||||
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onCatalogMetadataChanged();
|
||||
|
@ -37,16 +37,16 @@ mixin TagMixin on SourceBase {
|
|||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
await entry.catalog(background: true);
|
||||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata);
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
newMetadata.clear();
|
||||
}
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
@ -69,10 +69,10 @@ mixin TagMixin on SourceBase {
|
|||
|
||||
// by tag
|
||||
final Map<String, int> _filterEntryCountMap = {};
|
||||
final Map<String, AvesEntry> _filterRecentEntryMap = {};
|
||||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateTagFilterSummary([Set<AvesEntry> entries]) {
|
||||
Set<String> tags;
|
||||
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
|
||||
Set<String>? tags;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
|
@ -87,8 +87,8 @@ mixin TagMixin on SourceBase {
|
|||
return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where(filter.test).length);
|
||||
}
|
||||
|
||||
AvesEntry tagRecentEntry(TagFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
|
||||
AvesEntry? tagRecentEntry(TagFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -97,7 +97,7 @@ class CatalogMetadataChangedEvent {}
|
|||
class TagsChangedEvent {}
|
||||
|
||||
class TagSummaryInvalidatedEvent {
|
||||
final Set<String> tags;
|
||||
final Set<String>? tags;
|
||||
|
||||
const TagSummaryInvalidatedEvent(this.tags);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import 'package:aves/utils/math_utils.dart';
|
|||
import 'package:aves/utils/string_utils.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
// ignore: import_of_legacy_library_into_null_safe
|
||||
import 'package:fijkplayer/fijkplayer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
@ -54,16 +56,16 @@ class VideoMetadataFormatter {
|
|||
final value = kv.value;
|
||||
if (value != null) {
|
||||
try {
|
||||
String key;
|
||||
String keyLanguage;
|
||||
String? key;
|
||||
String? keyLanguage;
|
||||
// some keys have a language suffix, but they may be duplicates
|
||||
// we only keep the root key when they have the same value as the same key with no language
|
||||
final languageMatch = keyWithLanguagePattern.firstMatch(kv.key);
|
||||
if (languageMatch != null) {
|
||||
final code = languageMatch.group(2);
|
||||
final code = languageMatch.group(2)!;
|
||||
final native = _formatLanguage(code);
|
||||
if (native != code) {
|
||||
final root = languageMatch.group(1);
|
||||
final root = languageMatch.group(1)!;
|
||||
final rootValue = info[root];
|
||||
// skip if it is a duplicate of the same entry with no language
|
||||
if (rootValue == value) continue;
|
||||
|
@ -76,7 +78,7 @@ class VideoMetadataFormatter {
|
|||
}
|
||||
key = (key ?? (kv.key as String)).toLowerCase();
|
||||
|
||||
void save(String key, String value) {
|
||||
void save(String key, String? value) {
|
||||
if (value != null) {
|
||||
dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value;
|
||||
}
|
||||
|
@ -129,21 +131,26 @@ class VideoMetadataFormatter {
|
|||
case Keys.codecProfileId:
|
||||
if (codec == 'h264') {
|
||||
final profile = int.tryParse(value);
|
||||
if (profile != null && profile != 0) {
|
||||
final level = int.tryParse(info[Keys.codecLevel]);
|
||||
final levelString = info[Keys.codecLevel];
|
||||
if (profile != null && profile != 0 && levelString != null) {
|
||||
final level = int.tryParse(levelString) ?? 0;
|
||||
save('Codec Profile', H264.formatProfile(profile, level));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Keys.compatibleBrands:
|
||||
save('Compatible Brands', RegExp(r'.{4}').allMatches(value).map((m) => _formatBrand(m.group(0))).join(', '));
|
||||
final formattedBrands = RegExp(r'.{4}').allMatches(value).map((m) {
|
||||
final brand = m.group(0)!;
|
||||
return _formatBrand(brand);
|
||||
}).join(', ');
|
||||
save('Compatible Brands', formattedBrands);
|
||||
break;
|
||||
case Keys.creationTime:
|
||||
save('Creation Time', _formatDate(value));
|
||||
break;
|
||||
case Keys.date:
|
||||
if (value != '0') {
|
||||
final charCount = (value as String)?.length ?? 0;
|
||||
if (value is String && value != '0') {
|
||||
final charCount = value.length;
|
||||
save(charCount == 4 ? 'Year' : 'Date', value);
|
||||
}
|
||||
break;
|
||||
|
@ -222,10 +229,10 @@ class VideoMetadataFormatter {
|
|||
|
||||
static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)';
|
||||
|
||||
static String _formatCodecName(String value) => _codecNames[value] ?? value?.toUpperCase()?.replaceAll('_', ' ');
|
||||
static String _formatCodecName(String value) => _codecNames[value] ?? value.toUpperCase().replaceAll('_', ' ');
|
||||
|
||||
// input example: '2021-04-12T09:14:37.000000Z'
|
||||
static String _formatDate(String value) {
|
||||
static String? _formatDate(String value) {
|
||||
final date = DateTime.tryParse(value);
|
||||
if (date == null) return value;
|
||||
if (date == _epoch) return null;
|
||||
|
@ -236,10 +243,10 @@ class VideoMetadataFormatter {
|
|||
static String _formatDuration(String value) {
|
||||
final match = _durationPattern.firstMatch(value);
|
||||
if (match != null) {
|
||||
final h = int.tryParse(match.group(1));
|
||||
final m = int.tryParse(match.group(2));
|
||||
final s = int.tryParse(match.group(3));
|
||||
final millis = double.tryParse(match.group(4));
|
||||
final h = int.tryParse(match.group(1)!);
|
||||
final m = int.tryParse(match.group(2)!);
|
||||
final s = int.tryParse(match.group(3)!);
|
||||
final millis = double.tryParse(match.group(4)!);
|
||||
if (h != null && m != null && s != null && millis != null) {
|
||||
return formatPreciseDuration(Duration(
|
||||
hours: h,
|
||||
|
@ -258,15 +265,15 @@ class VideoMetadataFormatter {
|
|||
}
|
||||
|
||||
static String _formatLanguage(String value) {
|
||||
final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null);
|
||||
final language = Language.living639_2.firstWhereOrNull((language) => language.iso639_2 == value);
|
||||
return language?.native ?? value;
|
||||
}
|
||||
|
||||
// format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple)
|
||||
static String _formatLocation(String value) {
|
||||
static String? _formatLocation(String value) {
|
||||
final matches = _locationPattern.allMatches(value);
|
||||
if (matches.isNotEmpty) {
|
||||
final coordinates = matches.map((m) => double.tryParse(m.group(0))).toList();
|
||||
final coordinates = matches.map((m) => double.tryParse(m.group(0)!)).toList();
|
||||
if (coordinates.every((c) => c == 0)) return null;
|
||||
return coordinates.join(', ');
|
||||
}
|
||||
|
|
|
@ -7,17 +7,15 @@ class BrandColors {
|
|||
static const Color android = Color(0xFF3DDC84);
|
||||
static const Color flutter = Color(0xFF47D1FD);
|
||||
|
||||
static Color get(String text) {
|
||||
if (text != null) {
|
||||
switch (text.toLowerCase()) {
|
||||
case 'after effects':
|
||||
return adobeAfterEffects;
|
||||
case 'illustrator':
|
||||
return adobeIllustrator;
|
||||
case 'photoshop':
|
||||
case 'lightroom':
|
||||
return adobePhotoshop;
|
||||
}
|
||||
static Color? get(String text) {
|
||||
switch (text.toLowerCase()) {
|
||||
case 'after effects':
|
||||
return adobeAfterEffects;
|
||||
case 'illustrator':
|
||||
return adobeIllustrator;
|
||||
case 'photoshop':
|
||||
case 'lightroom':
|
||||
return adobePhotoshop;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -133,7 +133,7 @@ class Exif {
|
|||
}
|
||||
|
||||
static String getExifVersionDescription(String valueString) {
|
||||
if (valueString?.length == 4) {
|
||||
if (valueString.length == 4) {
|
||||
final major = int.tryParse(valueString.substring(0, 2));
|
||||
final minor = int.tryParse(valueString.substring(2, 4));
|
||||
if (major != null && minor != null) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
class Language {
|
||||
final String iso639_2, name, native;
|
||||
final String iso639_2, name;
|
||||
final String? native;
|
||||
|
||||
const Language({
|
||||
this.iso639_2,
|
||||
this.name,
|
||||
required this.iso639_2,
|
||||
required this.name,
|
||||
this.native,
|
||||
});
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ class AndroidAppService {
|
|||
final result = await platform.invokeMethod('getPackages');
|
||||
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
|
||||
// additional info for known directories
|
||||
final kakaoTalk = packages.firstWhere((package) => package.packageName == 'com.kakao.talk', orElse: () => null);
|
||||
final kakaoTalk = packages.firstWhereOrNull((package) => package.packageName == 'com.kakao.talk');
|
||||
if (kakaoTalk != null) {
|
||||
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
|
||||
}
|
||||
|
@ -31,19 +31,20 @@ class AndroidAppService {
|
|||
'packageName': packageName,
|
||||
'sizeDip': size,
|
||||
});
|
||||
return result as Uint8List;
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
static Future<bool> edit(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('edit', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('edit', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -52,10 +53,11 @@ class AndroidAppService {
|
|||
|
||||
static Future<bool> open(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('open', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('open', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -64,9 +66,10 @@ class AndroidAppService {
|
|||
|
||||
static Future<bool> openMap(String geoUri) async {
|
||||
try {
|
||||
return await platform.invokeMethod('openMap', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('openMap', <String, dynamic>{
|
||||
'geoUri': geoUri,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -75,10 +78,11 @@ class AndroidAppService {
|
|||
|
||||
static Future<bool> setAs(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('setAs', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('setAs', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -90,9 +94,10 @@ class AndroidAppService {
|
|||
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
|
||||
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
||||
try {
|
||||
return await platform.invokeMethod('share', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('share', <String, dynamic>{
|
||||
'urisByMimeType': urisByMimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -101,11 +106,12 @@ class AndroidAppService {
|
|||
|
||||
static Future<bool> shareSingle(String uri, String mimeType) async {
|
||||
try {
|
||||
return await platform.invokeMethod('share', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('share', <String, dynamic>{
|
||||
'urisByMimeType': {
|
||||
mimeType: [uri]
|
||||
},
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ class AndroidDebugService {
|
|||
static Future<Map> getContextDirs() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getContextDirs');
|
||||
return result as Map;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ class AndroidDebugService {
|
|||
static Future<Map> getEnv() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEnv');
|
||||
return result as Map;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ class AndroidDebugService {
|
|||
// returns map with all data available when decoding image bounds with `BitmapFactory`
|
||||
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -45,8 +45,8 @@ class AndroidDebugService {
|
|||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -60,8 +60,8 @@ class AndroidDebugService {
|
|||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -73,8 +73,8 @@ class AndroidDebugService {
|
|||
// returns map with all data available from `MediaMetadataRetriever`
|
||||
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -88,8 +88,8 @@ class AndroidDebugService {
|
|||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -102,8 +102,8 @@ class AndroidDebugService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('getTiffStructure', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
|
|
@ -10,24 +10,27 @@ class AppShortcutService {
|
|||
static const platform = MethodChannel('deckers.thibault/aves/shortcut');
|
||||
|
||||
// this ability will not change over the lifetime of the app
|
||||
static bool _canPin;
|
||||
static bool? _canPin;
|
||||
|
||||
static Future<bool> canPin() async {
|
||||
static Future<bool > canPin() async {
|
||||
if (_canPin != null) {
|
||||
return SynchronousFuture(_canPin);
|
||||
return SynchronousFuture(_canPin!);
|
||||
}
|
||||
|
||||
try {
|
||||
_canPin = await platform.invokeMethod('canPin');
|
||||
return _canPin;
|
||||
final result = await platform.invokeMethod('canPin');
|
||||
if (result != null) {
|
||||
_canPin = result;
|
||||
return result;
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> pin(String label, AvesEntry entry, Set<CollectionFilter> filters) async {
|
||||
Uint8List iconBytes;
|
||||
static Future<void> pin(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
|
||||
Uint8List? iconBytes;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await imageFileService.getThumbnail(
|
||||
|
@ -44,7 +47,7 @@ class AppShortcutService {
|
|||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
'label': label,
|
||||
'iconBytes': iconBytes,
|
||||
'filters': filters.where((filter) => filter != null).map((filter) => filter.toJson()).toList(),
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
|
|
@ -11,7 +11,7 @@ abstract class EmbeddedDataService {
|
|||
|
||||
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
|
||||
|
||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
||||
Future<Map> extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType);
|
||||
}
|
||||
|
||||
class PlatformEmbeddedDataService implements EmbeddedDataService {
|
||||
|
@ -25,7 +25,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
return (result as List).cast<Uint8List>();
|
||||
if (result != null) return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -41,11 +41,11 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
'displayName': '${entry.bestTitle} • Video',
|
||||
});
|
||||
return result;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -55,15 +55,15 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'uri': entry.uri,
|
||||
'displayName': '${entry.bestTitle} • Cover',
|
||||
});
|
||||
return result;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||
Future<Map> extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -73,10 +73,10 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'propPath': propPath,
|
||||
'propMimeType': propMimeType,
|
||||
});
|
||||
return result;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ class GeocodingService {
|
|||
|
||||
@immutable
|
||||
class Address {
|
||||
final String addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare;
|
||||
final String? addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare;
|
||||
|
||||
const Address({
|
||||
this.addressLine,
|
||||
|
|
|
@ -7,28 +7,30 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
// ignore: import_of_legacy_library_into_null_safe
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class ImageFileService {
|
||||
Future<AvesEntry> getEntry(String uri, String mimeType);
|
||||
Future<AvesEntry?> getEntry(String uri, String? mimeType);
|
||||
|
||||
Future<Uint8List> getSvg(
|
||||
String uri,
|
||||
String mimeType, {
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
});
|
||||
|
||||
Future<Uint8List> getImage(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int rotationDegrees,
|
||||
int? rotationDegrees,
|
||||
bool isFlipped, {
|
||||
int pageId,
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
int? pageId,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
});
|
||||
|
||||
// `rect`: region to decode, with coordinates in reference to `imageSize`
|
||||
|
@ -40,21 +42,21 @@ abstract class ImageFileService {
|
|||
int sampleSize,
|
||||
Rectangle<int> regionRect,
|
||||
Size imageSize, {
|
||||
int pageId,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
int? pageId,
|
||||
Object? taskKey,
|
||||
int? priority,
|
||||
});
|
||||
|
||||
Future<Uint8List> getThumbnail({
|
||||
@required String uri,
|
||||
@required String mimeType,
|
||||
@required int rotationDegrees,
|
||||
@required int pageId,
|
||||
@required bool isFlipped,
|
||||
@required int dateModifiedSecs,
|
||||
@required double extent,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
required String uri,
|
||||
required String mimeType,
|
||||
required int rotationDegrees,
|
||||
required int? pageId,
|
||||
required bool isFlipped,
|
||||
required int? dateModifiedSecs,
|
||||
required double extent,
|
||||
Object? taskKey,
|
||||
int? priority,
|
||||
});
|
||||
|
||||
Future<void> clearSizedThumbnailDiskCache();
|
||||
|
@ -63,27 +65,27 @@ abstract class ImageFileService {
|
|||
|
||||
bool cancelThumbnail(Object taskKey);
|
||||
|
||||
Future<T> resumeLoading<T>(Object taskKey);
|
||||
Future<T>? resumeLoading<T>(Object taskKey);
|
||||
|
||||
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
|
||||
|
||||
Stream<MoveOpEvent> move(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
});
|
||||
|
||||
Stream<ExportOpEvent> export(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required String mimeType,
|
||||
@required String destinationAlbum,
|
||||
required String mimeType,
|
||||
required String destinationAlbum,
|
||||
});
|
||||
|
||||
Future<Map> rename(AvesEntry entry, String newName);
|
||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
||||
|
||||
Future<Map> rotate(AvesEntry entry, {@required bool clockwise});
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||
|
||||
Future<Map> flip(AvesEntry entry);
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||
}
|
||||
|
||||
class PlatformImageFileService implements ImageFileService {
|
||||
|
@ -108,7 +110,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<AvesEntry> getEntry(String uri, String mimeType) async {
|
||||
Future<AvesEntry?> getEntry(String uri, String? mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -125,8 +127,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
Future<Uint8List> getSvg(
|
||||
String uri,
|
||||
String mimeType, {
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
}) =>
|
||||
getImage(
|
||||
uri,
|
||||
|
@ -141,11 +143,11 @@ class PlatformImageFileService implements ImageFileService {
|
|||
Future<Uint8List> getImage(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int rotationDegrees,
|
||||
int? rotationDegrees,
|
||||
bool isFlipped, {
|
||||
int pageId,
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
int? pageId,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
}) {
|
||||
try {
|
||||
final completer = Completer<Uint8List>.sync();
|
||||
|
@ -155,7 +157,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
'isFlipped': isFlipped,
|
||||
'pageId': pageId,
|
||||
}).listen(
|
||||
(data) {
|
||||
|
@ -182,7 +184,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
} on PlatformException catch (e) {
|
||||
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return Future.sync(() => null);
|
||||
return Future.sync(() => Uint8List(0));
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -194,9 +196,9 @@ class PlatformImageFileService implements ImageFileService {
|
|||
int sampleSize,
|
||||
Rectangle<int> regionRect,
|
||||
Size imageSize, {
|
||||
int pageId,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
int? pageId,
|
||||
Object? taskKey,
|
||||
int? priority,
|
||||
}) {
|
||||
return servicePolicy.call(
|
||||
() async {
|
||||
|
@ -213,11 +215,11 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'imageWidth': imageSize.width.toInt(),
|
||||
'imageHeight': imageSize.height.toInt(),
|
||||
});
|
||||
return result as Uint8List;
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return Uint8List(0);
|
||||
},
|
||||
priority: priority ?? ServiceCallPriority.getRegion,
|
||||
key: taskKey,
|
||||
|
@ -226,18 +228,18 @@ class PlatformImageFileService implements ImageFileService {
|
|||
|
||||
@override
|
||||
Future<Uint8List> getThumbnail({
|
||||
@required String uri,
|
||||
@required String mimeType,
|
||||
@required int rotationDegrees,
|
||||
@required int pageId,
|
||||
@required bool isFlipped,
|
||||
@required int dateModifiedSecs,
|
||||
@required double extent,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
required String uri,
|
||||
required String mimeType,
|
||||
required int rotationDegrees,
|
||||
required int? pageId,
|
||||
required bool isFlipped,
|
||||
required int? dateModifiedSecs,
|
||||
required double extent,
|
||||
Object? taskKey,
|
||||
int? priority,
|
||||
}) {
|
||||
if (mimeType == MimeTypes.svg) {
|
||||
return Future.sync(() => null);
|
||||
return Future.sync(() => Uint8List(0));
|
||||
}
|
||||
return servicePolicy.call(
|
||||
() async {
|
||||
|
@ -253,11 +255,11 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'pageId': pageId,
|
||||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
});
|
||||
return result as Uint8List;
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
return Uint8List(0);
|
||||
},
|
||||
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
|
||||
key: taskKey,
|
||||
|
@ -280,7 +282,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
|
||||
|
||||
@override
|
||||
Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
||||
Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
||||
|
||||
@override
|
||||
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
|
||||
|
@ -298,8 +300,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
@override
|
||||
Stream<MoveOpEvent> move(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
}) {
|
||||
try {
|
||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
|
@ -317,8 +319,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
@override
|
||||
Stream<ExportOpEvent> export(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required String mimeType,
|
||||
@required String destinationAlbum,
|
||||
required String mimeType,
|
||||
required String destinationAlbum,
|
||||
}) {
|
||||
try {
|
||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
|
@ -334,14 +336,14 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Map> rename(AvesEntry entry, String newName) async {
|
||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
|
||||
try {
|
||||
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'newName': newName,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -349,14 +351,14 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'clockwise': clockwise,
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -364,13 +366,13 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Map> flip(AvesEntry entry) async {
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
}) as Map;
|
||||
return result;
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -379,18 +381,18 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
typedef BytesReceivedCallback = void Function(int cumulative, int total);
|
||||
typedef BytesReceivedCallback = void Function(int cumulative, int? total);
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
class _OutputBuffer extends ByteConversionSinkBase {
|
||||
List<List<int>> _chunks = <List<int>>[];
|
||||
List<List<int>>? _chunks = <List<int>>[];
|
||||
int _contentLength = 0;
|
||||
Uint8List _bytes;
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void add(List<int> chunk) {
|
||||
assert(_bytes == null);
|
||||
_chunks.add(chunk);
|
||||
_chunks!.add(chunk);
|
||||
_contentLength += chunk.length;
|
||||
}
|
||||
|
||||
|
@ -402,8 +404,8 @@ class _OutputBuffer extends ByteConversionSinkBase {
|
|||
}
|
||||
_bytes = Uint8List(_contentLength);
|
||||
var offset = 0;
|
||||
for (final chunk in _chunks) {
|
||||
_bytes.setRange(offset, offset + chunk.length, chunk);
|
||||
for (final chunk in _chunks!) {
|
||||
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
||||
offset += chunk.length;
|
||||
}
|
||||
_chunks = null;
|
||||
|
@ -411,6 +413,6 @@ class _OutputBuffer extends ByteConversionSinkBase {
|
|||
|
||||
Uint8List get bytes {
|
||||
assert(_bytes != null);
|
||||
return _bytes;
|
||||
return _bytes!;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,8 +7,8 @@ class ImageOpEvent {
|
|||
final String uri;
|
||||
|
||||
const ImageOpEvent({
|
||||
this.success,
|
||||
this.uri,
|
||||
required this.success,
|
||||
required this.uri,
|
||||
});
|
||||
|
||||
factory ImageOpEvent.fromMap(Map map) {
|
||||
|
@ -34,7 +34,7 @@ class ImageOpEvent {
|
|||
class MoveOpEvent extends ImageOpEvent {
|
||||
final Map newFields;
|
||||
|
||||
const MoveOpEvent({bool success, String uri, this.newFields})
|
||||
const MoveOpEvent({required bool success, required String uri, required this.newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
|
@ -44,7 +44,7 @@ class MoveOpEvent extends ImageOpEvent {
|
|||
return MoveOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
newFields: map['newFields'],
|
||||
newFields: map['newFields'] ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -53,9 +53,9 @@ class MoveOpEvent extends ImageOpEvent {
|
|||
}
|
||||
|
||||
class ExportOpEvent extends MoveOpEvent {
|
||||
final int pageId;
|
||||
final int? pageId;
|
||||
|
||||
const ExportOpEvent({bool success, String uri, this.pageId, Map newFields})
|
||||
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
|
@ -67,7 +67,7 @@ class ExportOpEvent extends MoveOpEvent {
|
|||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
pageId: map['pageId'],
|
||||
newFields: map['newFields'],
|
||||
newFields: map['newFields'] ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,12 +3,13 @@ import 'dart:async';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
// ignore: import_of_legacy_library_into_null_safe
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class MediaStoreService {
|
||||
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds);
|
||||
|
||||
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById);
|
||||
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById);
|
||||
|
||||
// knownEntries: map of contentId -> dateModifiedSecs
|
||||
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
|
||||
|
@ -32,7 +33,7 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
|
||||
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
|
||||
'knownPathById': knownPathById,
|
||||
|
|
|
@ -10,15 +10,15 @@ abstract class MetadataService {
|
|||
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
Future<Map> getAllMetadata(AvesEntry entry);
|
||||
|
||||
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false});
|
||||
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false});
|
||||
|
||||
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry);
|
||||
Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry);
|
||||
|
||||
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry);
|
||||
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry);
|
||||
|
||||
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
|
||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||
|
||||
Future<String> getContentResolverProp(AvesEntry entry, String prop);
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||
}
|
||||
|
||||
class PlatformMetadataService implements MetadataService {
|
||||
|
@ -26,7 +26,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
|
||||
@override
|
||||
Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
if (entry.isSvg) return {};
|
||||
|
||||
try {
|
||||
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
|
||||
|
@ -34,7 +34,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
return result as Map;
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
@ -42,10 +42,10 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
|
||||
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
Future<CatalogMetadata> call() async {
|
||||
Future<CatalogMetadata?> call() async {
|
||||
try {
|
||||
// returns map with:
|
||||
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
|
||||
|
@ -80,7 +80,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
|
||||
Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
|
@ -98,7 +98,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
|
||||
Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -120,7 +120,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with values for:
|
||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||
|
@ -138,7 +138,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
try {
|
||||
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -22,19 +23,19 @@ class ServicePolicy {
|
|||
Future<T> call<T>(
|
||||
Future<T> Function() platformCall, {
|
||||
int priority = ServiceCallPriority.normal,
|
||||
Object key,
|
||||
Object? key,
|
||||
}) {
|
||||
Completer<T> completer;
|
||||
_Task task;
|
||||
_Task<T > task;
|
||||
key ??= platformCall.hashCode;
|
||||
final toResume = _paused.remove(key);
|
||||
if (toResume != null) {
|
||||
priority = toResume.item1;
|
||||
task = toResume.item2;
|
||||
task = toResume.item2 as _Task<T>;
|
||||
completer = task.completer;
|
||||
} else {
|
||||
completer = Completer<T>();
|
||||
task = _Task(
|
||||
task = _Task<T>(
|
||||
() async {
|
||||
try {
|
||||
completer.complete(await platformCall());
|
||||
|
@ -52,11 +53,11 @@ class ServicePolicy {
|
|||
return completer.future;
|
||||
}
|
||||
|
||||
Future<T> resume<T>(Object key) {
|
||||
Future<T>? resume<T>(Object key) {
|
||||
final toResume = _paused.remove(key);
|
||||
if (toResume != null) {
|
||||
final priority = toResume.item1;
|
||||
final task = toResume.item2;
|
||||
final task = toResume.item2 as _Task<T >;
|
||||
_getQueue(priority)[key] = task;
|
||||
_pickNext();
|
||||
return task.completer.future;
|
||||
|
@ -70,10 +71,10 @@ class ServicePolicy {
|
|||
void _pickNext() {
|
||||
_notifyQueueState();
|
||||
if (_runningQueue.length >= concurrentTaskMax) return;
|
||||
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
|
||||
final queue = _queues.entries.firstWhereOrNull((kv) => kv.value.isNotEmpty)?.value;
|
||||
if (queue != null && queue.isNotEmpty) {
|
||||
final key = queue.keys.first;
|
||||
final task = queue.remove(key);
|
||||
final task = queue.remove(key)!;
|
||||
_runningQueue[key] = task;
|
||||
task.callback();
|
||||
}
|
||||
|
@ -109,9 +110,9 @@ class ServicePolicy {
|
|||
}
|
||||
}
|
||||
|
||||
class _Task {
|
||||
class _Task<T> {
|
||||
final VoidCallback callback;
|
||||
final Completer completer;
|
||||
final Completer<T> completer;
|
||||
|
||||
const _Task(this.callback, this.completer);
|
||||
}
|
||||
|
|
|
@ -11,16 +11,16 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
final pContext = getIt<p.Context>();
|
||||
final availability = getIt<AvesAvailability>();
|
||||
final metadataDb = getIt<MetadataDb>();
|
||||
final p.Context pContext = getIt<p.Context>();
|
||||
final AvesAvailability availability = getIt<AvesAvailability>();
|
||||
final MetadataDb metadataDb = getIt<MetadataDb>();
|
||||
|
||||
final embeddedDataService = getIt<EmbeddedDataService>();
|
||||
final imageFileService = getIt<ImageFileService>();
|
||||
final mediaStoreService = getIt<MediaStoreService>();
|
||||
final metadataService = getIt<MetadataService>();
|
||||
final storageService = getIt<StorageService>();
|
||||
final timeService = getIt<TimeService>();
|
||||
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
|
||||
final ImageFileService imageFileService = getIt<ImageFileService>();
|
||||
final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
|
||||
final MetadataService metadataService = getIt<MetadataService>();
|
||||
final StorageService storageService = getIt<StorageService>();
|
||||
final TimeService timeService = getIt<TimeService>();
|
||||
|
||||
void initPlatformServices() {
|
||||
getIt.registerLazySingleton<p.Context>(() => p.Context());
|
||||
|
|
|
@ -3,12 +3,13 @@ import 'dart:async';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
// ignore: import_of_legacy_library_into_null_safe
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class StorageService {
|
||||
Future<Set<StorageVolume>> getStorageVolumes();
|
||||
|
||||
Future<int> getFreeSpace(StorageVolume volume);
|
||||
Future<int?> getFreeSpace(StorageVolume volume);
|
||||
|
||||
Future<List<String>> getGrantedDirectories();
|
||||
|
||||
|
@ -25,7 +26,7 @@ abstract class StorageService {
|
|||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||
|
||||
// returns media URI
|
||||
Future<Uri> scanFile(String path, String mimeType);
|
||||
Future<Uri?> scanFile(String path, String mimeType);
|
||||
}
|
||||
|
||||
class PlatformStorageService implements StorageService {
|
||||
|
@ -44,16 +45,16 @@ class PlatformStorageService implements StorageService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<int> getFreeSpace(StorageVolume volume) async {
|
||||
Future<int?> getFreeSpace(StorageVolume volume) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
|
||||
'path': volume.path,
|
||||
});
|
||||
return result as int;
|
||||
return result as int?;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -85,22 +86,26 @@ class PlatformStorageService implements StorageService {
|
|||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||
if (result != null) {
|
||||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getRestrictedDirectories');
|
||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||
if (result != null) {
|
||||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
|
@ -111,7 +116,7 @@ class PlatformStorageService implements StorageService {
|
|||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'path': volumePath,
|
||||
}).listen(
|
||||
(data) => completer.complete(data as bool),
|
||||
(data) => completer.complete(data as bool?),
|
||||
onError: completer.completeError,
|
||||
onDone: () {
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
|
@ -129,9 +134,10 @@ class PlatformStorageService implements StorageService {
|
|||
@override
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
|
@ -140,14 +146,14 @@ class PlatformStorageService implements StorageService {
|
|||
|
||||
// returns media URI
|
||||
@override
|
||||
Future<Uri> scanFile(String path, String mimeType) async {
|
||||
Future<Uri?> scanFile(String path, String mimeType) async {
|
||||
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
||||
try {
|
||||
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
|
||||
final result = await platform.invokeMethod('scanFile', <String, dynamic>{
|
||||
'path': path,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
return Uri.tryParse(uriString ?? '');
|
||||
if (result != null) return Uri.tryParse(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/string_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
@ -15,15 +16,15 @@ class SvgMetadataService {
|
|||
static const _textElements = ['title', 'desc'];
|
||||
static const _metadataElement = 'metadata';
|
||||
|
||||
static Future<Size> getSize(AvesEntry entry) async {
|
||||
static Future<Size?> getSize(AvesEntry entry) async {
|
||||
try {
|
||||
final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
|
||||
|
||||
final document = XmlDocument.parse(utf8.decode(data));
|
||||
final root = document.rootElement;
|
||||
|
||||
String getAttribute(String attributeName) => root.attributes.firstWhere((a) => a.name.qualified == attributeName, orElse: () => null)?.value;
|
||||
double tryParseWithoutUnit(String s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
|
||||
String? getAttribute(String attributeName) => root.attributes.firstWhereOrNull((a) => a.name.qualified == attributeName)?.value;
|
||||
double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
|
||||
|
||||
final width = tryParseWithoutUnit(getAttribute('width'));
|
||||
final height = tryParseWithoutUnit(getAttribute('height'));
|
||||
|
@ -37,7 +38,7 @@ class SvgMetadataService {
|
|||
if (parts.length == 4) {
|
||||
final vbWidth = tryParseWithoutUnit(parts[2]);
|
||||
final vbHeight = tryParseWithoutUnit(parts[3]);
|
||||
if (vbWidth > 0 && vbHeight > 0) {
|
||||
if (vbWidth != null && vbWidth > 0 && vbHeight != null && vbHeight > 0) {
|
||||
return Size(vbWidth, vbHeight);
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +67,7 @@ class SvgMetadataService {
|
|||
|
||||
final docDir = Map.fromEntries([
|
||||
...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(formatKey(a.name.qualified), a.value)),
|
||||
..._textElements.map((name) => MapEntry(formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null),
|
||||
..._textElements.map((name) => MapEntry(formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null).cast<MapEntry<String, String >>(),
|
||||
]);
|
||||
|
||||
final metadata = root.getElement(_metadataElement);
|
||||
|
@ -80,7 +81,7 @@ class SvgMetadataService {
|
|||
};
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
||||
return null;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,14 +2,14 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class TimeService {
|
||||
Future<String> getDefaultTimeZone();
|
||||
Future<String?> getDefaultTimeZone();
|
||||
}
|
||||
|
||||
class PlatformTimeService implements TimeService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/time');
|
||||
|
||||
@override
|
||||
Future<String> getDefaultTimeZone() async {
|
||||
Future<String?> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e) {
|
||||
|
|
|
@ -4,10 +4,11 @@ import 'package:flutter/services.dart';
|
|||
class ViewerService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/viewer');
|
||||
|
||||
static Future<Map> getIntentData() async {
|
||||
static Future<Map<String, dynamic>> getIntentData() async {
|
||||
try {
|
||||
// returns nullable map with 'action' and possibly 'uri' 'mimeType'
|
||||
return await platform.invokeMethod('getIntentData') as Map;
|
||||
final result = await platform.invokeMethod('getIntentData');
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
|
|
|
@ -32,14 +32,13 @@ class Durations {
|
|||
static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
|
||||
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
|
||||
static const sectionHeaderAnimation = Duration(milliseconds: 200);
|
||||
static const thumbnailTransition = Duration(milliseconds: 200);
|
||||
static const thumbnailOverlayAnimation = Duration(milliseconds: 200);
|
||||
|
||||
// search animations
|
||||
static const filterRowExpandAnimation = Duration(milliseconds: 300);
|
||||
|
||||
// viewer animations
|
||||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 300);
|
||||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
|
||||
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
||||
|
@ -56,8 +55,10 @@ class Durations {
|
|||
|
||||
// delays & refresh intervals
|
||||
static const opToastDisplay = Duration(seconds: 3);
|
||||
static const opToastActionDisplay = Duration(seconds: 5);
|
||||
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
|
||||
static const highlightJumpDelay = Duration(milliseconds: 400);
|
||||
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
// ignore: import_of_legacy_library_into_null_safe
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
|
||||
class AIcons {
|
||||
|
@ -19,7 +20,7 @@ class AIcons {
|
|||
static const IconData location = Icons.place_outlined;
|
||||
static const IconData locationOff = Icons.location_off_outlined;
|
||||
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
||||
static const IconData raw = Icons.camera_outlined;
|
||||
static const IconData raw = Icons.raw_on_outlined;
|
||||
static const IconData shooting = Icons.camera_outlined;
|
||||
static const IconData removableStorage = Icons.sd_storage_outlined;
|
||||
static const IconData sensorControl = Icons.explore_outlined;
|
||||
|
|
|
@ -9,13 +9,13 @@ class Themes {
|
|||
static final darkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: _accentColor,
|
||||
scaffoldBackgroundColor: Colors.grey[900],
|
||||
scaffoldBackgroundColor: Colors.grey.shade900,
|
||||
dialogBackgroundColor: Colors.grey[850],
|
||||
toggleableActiveColor: _accentColor,
|
||||
tooltipTheme: TooltipThemeData(
|
||||
tooltipTheme: const TooltipThemeData(
|
||||
verticalOffset: 32,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
appBarTheme: const AppBarTheme(
|
||||
textTheme: TextTheme(
|
||||
headline6: TextStyle(
|
||||
fontSize: 20,
|
||||
|
@ -24,15 +24,16 @@ class Themes {
|
|||
),
|
||||
),
|
||||
),
|
||||
colorScheme: ColorScheme.dark(
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accentColor,
|
||||
secondary: _accentColor,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: Colors.grey[800],
|
||||
contentTextStyle: TextStyle(
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
actionTextColor: _accentColor,
|
||||
contentTextStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
|
|
|
@ -2,13 +2,14 @@ import 'package:aves/services/android_app_service.dart';
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
||||
class AndroidFileUtils {
|
||||
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||
Set<StorageVolume> storageVolumes = {};
|
||||
Set<Package> _packages = {};
|
||||
List<String> _potentialAppDirs = [];
|
||||
|
@ -22,7 +23,7 @@ class AndroidFileUtils {
|
|||
Future<void> init() async {
|
||||
storageVolumes = await storageService.getStorageVolumes();
|
||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? '/';
|
||||
dcimPath = pContext.join(primaryStorage, 'DCIM');
|
||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||
|
@ -35,16 +36,17 @@ class AndroidFileUtils {
|
|||
appNameChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
bool isCameraPath(String path) => path != null && path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
|
||||
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
|
||||
|
||||
bool isScreenshotsPath(String path) => path != null && (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('Screenshots');
|
||||
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('Screenshots');
|
||||
|
||||
bool isScreenRecordingsPath(String path) => path != null && (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords'));
|
||||
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords'));
|
||||
|
||||
bool isDownloadPath(String path) => path == downloadPath;
|
||||
|
||||
StorageVolume getStorageVolume(String path) {
|
||||
final volume = storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null);
|
||||
StorageVolume? getStorageVolume(String? path) {
|
||||
if (path == null) return null;
|
||||
final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path));
|
||||
// storage volume path includes trailing '/', but argument path may or may not,
|
||||
// which is an issue when the path is at the root
|
||||
return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/');
|
||||
|
@ -53,27 +55,25 @@ class AndroidFileUtils {
|
|||
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||
|
||||
AlbumType getAlbumType(String albumPath) {
|
||||
if (albumPath != null) {
|
||||
if (isCameraPath(albumPath)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||
if (isCameraPath(albumPath)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||
|
||||
final dir = pContext.split(albumPath).last;
|
||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
|
||||
final dir = pContext.split(albumPath).last;
|
||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
}
|
||||
return AlbumType.regular;
|
||||
}
|
||||
|
||||
String getAlbumAppPackageName(String albumPath) {
|
||||
if (albumPath == null) return null;
|
||||
String? getAlbumAppPackageName(String albumPath) {
|
||||
final dir = pContext.split(albumPath).last;
|
||||
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
||||
final package = _launcherPackages.firstWhereOrNull((package) => package.potentialDirs.contains(dir));
|
||||
return package?.packageName;
|
||||
}
|
||||
|
||||
String getCurrentAppName(String packageName) {
|
||||
final package = _packages.firstWhere((package) => package.packageName == packageName, orElse: () => null);
|
||||
String? getCurrentAppName(String packageName) {
|
||||
final package = _packages.firstWhereOrNull((package) => package.packageName == packageName);
|
||||
return package?.currentLabel;
|
||||
}
|
||||
}
|
||||
|
@ -81,25 +81,26 @@ class AndroidFileUtils {
|
|||
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
|
||||
|
||||
class Package {
|
||||
final String packageName, currentLabel, englishLabel;
|
||||
final String packageName;
|
||||
final String? currentLabel, englishLabel;
|
||||
final bool categoryLauncher, isSystem;
|
||||
final Set<String> ownedDirs = {};
|
||||
|
||||
Package({
|
||||
this.packageName,
|
||||
this.currentLabel,
|
||||
this.englishLabel,
|
||||
this.categoryLauncher,
|
||||
this.isSystem,
|
||||
required this.packageName,
|
||||
required this.currentLabel,
|
||||
required this.englishLabel,
|
||||
required this.categoryLauncher,
|
||||
required this.isSystem,
|
||||
});
|
||||
|
||||
factory Package.fromMap(Map map) {
|
||||
return Package(
|
||||
packageName: map['packageName'],
|
||||
packageName: map['packageName'] ?? '',
|
||||
currentLabel: map['currentLabel'],
|
||||
englishLabel: map['englishLabel'],
|
||||
categoryLauncher: map['categoryLauncher'],
|
||||
isSystem: map['isSystem'],
|
||||
categoryLauncher: map['categoryLauncher'] ?? false,
|
||||
isSystem: map['isSystem'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -107,7 +108,7 @@ class Package {
|
|||
currentLabel,
|
||||
englishLabel,
|
||||
...ownedDirs,
|
||||
].where((dir) => dir != null).toSet();
|
||||
].where((dir) => dir != null).cast<String>().toSet();
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
|
||||
|
@ -115,24 +116,25 @@ class Package {
|
|||
|
||||
@immutable
|
||||
class StorageVolume {
|
||||
final String _description, path, state;
|
||||
final String? _description;
|
||||
final String path, state;
|
||||
final bool isPrimary, isRemovable;
|
||||
|
||||
const StorageVolume({
|
||||
String description,
|
||||
this.isPrimary,
|
||||
this.isRemovable,
|
||||
this.path,
|
||||
this.state,
|
||||
required String? description,
|
||||
required this.isPrimary,
|
||||
required this.isRemovable,
|
||||
required this.path,
|
||||
required this.state,
|
||||
}) : _description = description;
|
||||
|
||||
String getDescription(BuildContext context) {
|
||||
if (_description != null) return _description;
|
||||
String getDescription(BuildContext? context) {
|
||||
if (_description != null) return _description!;
|
||||
// ideally, the context should always be provided, but in some cases (e.g. album comparison),
|
||||
// this would require numerous additional methods to have the context as argument
|
||||
// for such a minor benefit: fallback volume description on Android < N
|
||||
if (isPrimary) return context?.l10n?.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage';
|
||||
return context?.l10n?.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card';
|
||||
if (isPrimary) return context?.l10n.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage';
|
||||
return context?.l10n.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card';
|
||||
}
|
||||
|
||||
factory StorageVolume.fromMap(Map map) {
|
||||
|
@ -152,19 +154,19 @@ class VolumeRelativeDirectory {
|
|||
final String volumePath, relativeDir;
|
||||
|
||||
const VolumeRelativeDirectory({
|
||||
this.volumePath,
|
||||
this.relativeDir,
|
||||
required this.volumePath,
|
||||
required this.relativeDir,
|
||||
});
|
||||
|
||||
factory VolumeRelativeDirectory.fromMap(Map map) {
|
||||
static VolumeRelativeDirectory fromMap(Map map) {
|
||||
return VolumeRelativeDirectory(
|
||||
volumePath: map['volumePath'],
|
||||
volumePath: map['volumePath'] ?? '',
|
||||
relativeDir: map['relativeDir'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// prefer static method over a null returning factory constructor
|
||||
static VolumeRelativeDirectory fromPath(String dirPath) {
|
||||
static VolumeRelativeDirectory? fromPath(String dirPath) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
if (volume == null) return null;
|
||||
|
||||
|
@ -177,7 +179,7 @@ class VolumeRelativeDirectory {
|
|||
}
|
||||
|
||||
String getVolumeDescription(BuildContext context) {
|
||||
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
|
||||
final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath);
|
||||
return volume?.getDescription(context) ?? volumePath;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,22 +2,22 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin
|
||||
class AChangeNotifier implements Listenable {
|
||||
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>();
|
||||
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
|
||||
|
||||
@override
|
||||
void addListener(VoidCallback listener) => _listeners.add(listener);
|
||||
void addListener(VoidCallback listener) => _listeners!.add(listener);
|
||||
|
||||
@override
|
||||
void removeListener(VoidCallback listener) => _listeners.remove(listener);
|
||||
void removeListener(VoidCallback listener) => _listeners!.remove(listener);
|
||||
|
||||
void dispose() => _listeners = null;
|
||||
|
||||
void notifyListeners() {
|
||||
if (_listeners == null) return;
|
||||
final localListeners = List<VoidCallback>.from(_listeners);
|
||||
final localListeners = List<VoidCallback>.from(_listeners!);
|
||||
for (final listener in localListeners) {
|
||||
try {
|
||||
if (_listeners.contains(listener)) listener();
|
||||
if (_listeners!.contains(listener)) listener();
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
|
||||
}
|
||||
|
|
|
@ -15,10 +15,12 @@ class Constants {
|
|||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
);
|
||||
|
||||
static const embossShadow = Shadow(
|
||||
color: Colors.black87,
|
||||
offset: Offset(0.5, 1.0),
|
||||
);
|
||||
static const embossShadows = [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
offset: Offset(0.5, 1.0),
|
||||
)
|
||||
];
|
||||
|
||||
static const overlayUnknown = '—'; // em dash
|
||||
|
||||
|
@ -40,7 +42,7 @@ class Constants {
|
|||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Android-TiffBitmapFactory',
|
||||
name: 'Android-TiffBitmapFactory (Aves fork)',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt',
|
||||
sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory',
|
||||
|
@ -67,10 +69,10 @@ class Constants {
|
|||
|
||||
static const List<Dependency> flutterPlugins = [
|
||||
Dependency(
|
||||
name: 'Connectivity',
|
||||
name: 'Connectivity Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'FlutterFire (Core, Analytics, Crashlytics)',
|
||||
|
@ -79,7 +81,7 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
Dependency(
|
||||
name: 'fijkplayer',
|
||||
name: 'fijkplayer (Aves fork)',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/fijkplayer',
|
||||
|
@ -97,10 +99,10 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Package Info',
|
||||
name: 'Package Info Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Permission Handler',
|
||||
|
@ -127,10 +129,10 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/tekartik/sqflite',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Streams Channel',
|
||||
name: 'Streams Channel (Aves fork)',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/loup-v/streams_channel/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/loup-v/streams_channel',
|
||||
licenseUrl: 'https://github.com/deckerst/aves_streams_channel/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
|
||||
),
|
||||
Dependency(
|
||||
name: 'URL Launcher',
|
||||
|
@ -229,7 +231,7 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Expansion Tile Card',
|
||||
name: 'Expansion Tile Card (Aves fork)',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
|
||||
|
@ -249,8 +251,8 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Flutter Markdown',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/flutter_markdown/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/flutter_markdown',
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Staggered Animations',
|
||||
|
@ -310,9 +312,9 @@ class Dependency {
|
|||
final String licenseUrl;
|
||||
|
||||
const Dependency({
|
||||
@required this.name,
|
||||
@required this.license,
|
||||
@required this.licenseUrl,
|
||||
@required this.sourceUrl,
|
||||
required this.name,
|
||||
required this.license,
|
||||
required this.licenseUrl,
|
||||
required this.sourceUrl,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
|
|||
class Debouncer {
|
||||
final Duration delay;
|
||||
|
||||
Timer _timer;
|
||||
Timer? _timer;
|
||||
|
||||
Debouncer({@required this.delay});
|
||||
Debouncer({required this.delay});
|
||||
|
||||
void call(Function action) {
|
||||
void call(VoidCallback action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delay, action);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
final double _log2 = log(2);
|
||||
const double _piOver180 = pi / 180.0;
|
||||
|
||||
|
@ -9,12 +7,12 @@ double toDegrees(num radians) => radians / _piOver180;
|
|||
|
||||
double toRadians(num degrees) => degrees * _piOver180;
|
||||
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor());
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()) as int;
|
||||
|
||||
double roundToPrecision(final double value, {@required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||
double roundToPrecision(final double value, {required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
||||
|
||||
// e.g. x=12345, precision=3 should return 13000
|
||||
int ceilBy(num x, int precision) {
|
||||
final factor = pow(10, precision);
|
||||
return (x / factor).ceil() * factor;
|
||||
return (x / factor).ceil() * (factor as int);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ extension ExtraString on String {
|
|||
static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])');
|
||||
|
||||
String toSentenceCase() {
|
||||
var s = replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase());
|
||||
var s = replaceFirstMapped(RegExp('.'), (m) => m.group(0)!.toUpperCase());
|
||||
return s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(_sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,15 +15,15 @@ String formatPreciseDuration(Duration d) {
|
|||
}
|
||||
|
||||
extension ExtraDateTime on DateTime {
|
||||
bool isAtSameYearAs(DateTime other) => this?.year == other?.year;
|
||||
bool isAtSameYearAs(DateTime? other) => year == other?.year;
|
||||
|
||||
bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && this?.month == other?.month;
|
||||
bool isAtSameMonthAs(DateTime? other) => isAtSameYearAs(other) && month == other?.month;
|
||||
|
||||
bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && this?.day == other?.day;
|
||||
bool isAtSameDayAs(DateTime? other) => isAtSameMonthAs(other) && day == other?.day;
|
||||
|
||||
bool get isToday => isAtSameDayAs(DateTime.now());
|
||||
|
||||
bool get isYesterday => isAtSameDayAs(DateTime.now().subtract(Duration(days: 1)));
|
||||
bool get isYesterday => isAtSameDayAs(DateTime.now().subtract(const Duration(days: 1)));
|
||||
|
||||
bool get isThisMonth => isAtSameMonthAs(DateTime.now());
|
||||
|
||||
|
|
|
@ -18,15 +18,15 @@ class AboutPage extends StatelessWidget {
|
|||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
AppReference(),
|
||||
Divider(),
|
||||
const Divider(),
|
||||
AboutUpdate(),
|
||||
AboutCredits(),
|
||||
Divider(),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/widgets/common/basic/link_chip.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_logo.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info/package_info.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppReference extends StatefulWidget {
|
||||
@override
|
||||
|
@ -13,7 +13,7 @@ class AppReference extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AppReferenceState extends State<AppReference> {
|
||||
Future<PackageInfo> _packageInfoLoader;
|
||||
late Future<PackageInfo> _packageInfoLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -28,14 +28,14 @@ class _AppReferenceState extends State<AppReference> {
|
|||
children: [
|
||||
_buildAvesLine(),
|
||||
_buildFlutterLine(),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvesLine() {
|
||||
final style = TextStyle(
|
||||
const style = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.normal,
|
||||
letterSpacing: 1.0,
|
||||
|
@ -47,7 +47,7 @@ class _AppReferenceState extends State<AppReference> {
|
|||
builder: (context, snapshot) {
|
||||
return LinkChip(
|
||||
leading: AvesLogo(
|
||||
size: style.fontSize * MediaQuery.textScaleFactorOf(context) * 1.25,
|
||||
size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.25,
|
||||
),
|
||||
text: '${context.l10n.appName} ${snapshot.data?.version}',
|
||||
url: 'https://github.com/deckerst/aves',
|
||||
|
@ -59,16 +59,16 @@ class _AppReferenceState extends State<AppReference> {
|
|||
|
||||
Widget _buildFlutterLine() {
|
||||
final style = DefaultTextStyle.of(context).style;
|
||||
final subColor = style.color.withOpacity(.6);
|
||||
final subColor = style.color!.withOpacity(.6);
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 4),
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
child: FlutterLogo(
|
||||
size: style.fontSize * 1.25,
|
||||
size: style.fontSize! * 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -9,12 +9,12 @@ class AboutCredits extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 48),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle),
|
||||
|
@ -24,7 +24,7 @@ class AboutCredits extends StatelessWidget {
|
|||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: context.l10n.aboutCreditsWorldAtlas1),
|
||||
WidgetSpan(
|
||||
const WidgetSpan(
|
||||
child: LinkChip(
|
||||
text: 'World Atlas',
|
||||
url: 'https://github.com/topojson/world-atlas',
|
||||
|
@ -36,7 +36,7 @@ class AboutCredits extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -12,8 +12,8 @@ class Licenses extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _LicensesState extends State<Licenses> {
|
||||
final ValueNotifier<String> _expandedNotifier = ValueNotifier(null);
|
||||
List<Dependency> _platform, _flutterPlugins, _flutterPackages, _dartPackages;
|
||||
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
||||
late List<Dependency> _platform, _flutterPlugins, _flutterPackages, _dartPackages;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -36,12 +36,12 @@ class _LicensesState extends State<Licenses> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
_buildHeader(),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
AvesExpansionTile(
|
||||
title: context.l10n.aboutLicensesAndroidLibraries,
|
||||
color: BrandColors.android,
|
||||
|
@ -76,7 +76,7 @@ class _LicensesState extends State<Licenses> {
|
|||
// as of Flutter v1.22.4, `cardColor` is used as a background color by `LicensePage`
|
||||
cardColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
child: LicensePage(),
|
||||
child: const LicensePage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -91,18 +91,18 @@ class _LicensesState extends State<Licenses> {
|
|||
|
||||
Widget _buildHeader() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 48),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(context.l10n.aboutLicenses, style: Constants.titleTextStyle),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
const SizedBox(height: 8),
|
||||
Text(context.l10n.aboutLicensesBanner),
|
||||
],
|
||||
),
|
||||
|
@ -118,21 +118,21 @@ class LicenseRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final bodyTextStyle = textTheme.bodyText2;
|
||||
final subColor = bodyTextStyle.color.withOpacity(.6);
|
||||
final bodyTextStyle = textTheme.bodyText2!;
|
||||
final subColor = bodyTextStyle.color!.withOpacity(.6);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LinkChip(
|
||||
text: package.name,
|
||||
url: package.sourceUrl,
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 16),
|
||||
padding: const EdgeInsetsDirectional.only(start: 16),
|
||||
child: LinkChip(
|
||||
text: package.license,
|
||||
url: package.licenseUrl,
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AboutNewsBadge extends StatelessWidget {
|
||||
const AboutNewsBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Icon(
|
||||
return const Icon(
|
||||
Icons.circle,
|
||||
size: 12,
|
||||
color: Colors.red,
|
||||
|
|
|
@ -11,7 +11,7 @@ class AboutUpdate extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AboutUpdateState extends State<AboutUpdate> {
|
||||
Future<bool> _updateChecker;
|
||||
late Future<bool> _updateChecker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -25,22 +25,22 @@ class _AboutUpdateState extends State<AboutUpdate> {
|
|||
future: _updateChecker,
|
||||
builder: (context, snapshot) {
|
||||
final newVersionAvailable = snapshot.data == true;
|
||||
if (!newVersionAvailable) return SizedBox();
|
||||
if (!newVersionAvailable) return const SizedBox();
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 48),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
const WidgetSpan(
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 8),
|
||||
child: AboutNewsBadge(),
|
||||
|
@ -61,7 +61,7 @@ class _AboutUpdateState extends State<AboutUpdate> {
|
|||
child: LinkChip(
|
||||
text: context.l10n.aboutUpdateGithub,
|
||||
url: 'https://github.com/deckerst/aves/releases',
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
|
@ -70,7 +70,7 @@ class _AboutUpdateState extends State<AboutUpdate> {
|
|||
child: LinkChip(
|
||||
text: context.l10n.aboutUpdateGooglePlay,
|
||||
url: 'https://play.google.com/store/apps/details?id=deckers.thibault.aves',
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
|
@ -78,11 +78,11 @@ class _AboutUpdateState extends State<AboutUpdate> {
|
|||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
const Divider(),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
173
lib/widgets/aves_app.dart
Normal file
173
lib/widgets/aves_app.dart
Normal file
|
@ -0,0 +1,173 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/media_store_source.dart';
|
||||
import 'package:aves/services/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/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_analytics/observer.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:overlay_support/overlay_support.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AvesApp extends StatefulWidget {
|
||||
@override
|
||||
_AvesAppState createState() => _AvesAppState();
|
||||
}
|
||||
|
||||
class _AvesAppState extends State<AvesApp> {
|
||||
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
|
||||
late Future<void> _appSetup;
|
||||
final _mediaStoreSource = MediaStoreSource();
|
||||
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||
final Set<String> changedUris = {};
|
||||
|
||||
// observers are not registered when using the same list object with different items
|
||||
// the list itself needs to be reassigned
|
||||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _contentChangeChannel = const EventChannel('deckers.thibault/aves/contentchange');
|
||||
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initPlatformServices();
|
||||
_appSetup = _setup();
|
||||
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String?));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// place the settings provider above `MaterialApp`
|
||||
// so it can be used during navigation transitions
|
||||
return ChangeNotifierProvider<Settings>.value(
|
||||
value: settings,
|
||||
child: ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: appModeNotifier,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
||||
final home = initialized
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
||||
);
|
||||
return Selector<Settings, Locale?>(
|
||||
selector: (context, s) => s.locale,
|
||||
builder: (context, settingsLocale, child) {
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
locale: settingsLocale,
|
||||
localizationsDelegates: [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// checkerboardRasterCacheImages: true,
|
||||
// checkerboardOffscreenLayers: true,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(Object error) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(AIcons.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(error.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) {
|
||||
final crashlytics = FirebaseCrashlytics.instance;
|
||||
FlutterError.onError = crashlytics.recordFlutterError;
|
||||
crashlytics.setCustomKey('locales', window.locales.join(', '));
|
||||
final now = DateTime.now();
|
||||
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
|
||||
crashlytics.setCustomKey(
|
||||
'build_mode',
|
||||
kReleaseMode
|
||||
? 'release'
|
||||
: kProfileMode
|
||||
? 'profile'
|
||||
: 'debug');
|
||||
});
|
||||
await settings.init();
|
||||
await settings.initFirebase();
|
||||
_navigatorObservers = [
|
||||
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
|
||||
CrashlyticsRouteTracker(),
|
||||
];
|
||||
}
|
||||
|
||||
void _onNewIntent(Map? intentData) {
|
||||
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
|
||||
|
||||
// do not reset when relaunching the app
|
||||
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
|
||||
|
||||
FirebaseCrashlytics.instance.log('New intent');
|
||||
_navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute(
|
||||
settings: const RouteSettings(name: HomePage.routeName),
|
||||
builder: (_) => getFirstPage(intentData: intentData),
|
||||
));
|
||||
}
|
||||
|
||||
void _onContentChange(String? uri) {
|
||||
if (uri != null) changedUris.add(uri);
|
||||
if (changedUris.isNotEmpty) {
|
||||
_contentChangeDebouncer(() async {
|
||||
final todo = changedUris.toSet();
|
||||
changedUris.clear();
|
||||
final tempUris = await _mediaStoreSource.refreshUris(todo);
|
||||
if (tempUris.isNotEmpty) {
|
||||
changedUris.addAll(tempUris);
|
||||
_onContentChange(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -35,9 +35,9 @@ class CollectionAppBar extends StatefulWidget {
|
|||
final CollectionLens collection;
|
||||
|
||||
const CollectionAppBar({
|
||||
Key key,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.collection,
|
||||
Key? key,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.collection,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -46,9 +46,9 @@ class CollectionAppBar extends StatefulWidget {
|
|||
|
||||
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _searchFieldController = TextEditingController();
|
||||
EntrySetActionDelegate _actionDelegate;
|
||||
AnimationController _browseToSelectAnimation;
|
||||
Future<bool> _canAddShortcutsLoader;
|
||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -59,16 +59,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actionDelegate = EntrySetActionDelegate(
|
||||
collection: collection,
|
||||
);
|
||||
_browseToSelectAnimation = AnimationController(
|
||||
duration: Durations.iconAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_canAddShortcutsLoader = AppShortcutService.canPin();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight());
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -127,8 +124,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
Widget _buildAppBarLeading() {
|
||||
VoidCallback onPressed;
|
||||
String tooltip;
|
||||
VoidCallback? onPressed;
|
||||
String? tooltip;
|
||||
if (collection.isBrowsing) {
|
||||
onPressed = Scaffold.of(context).openDrawer;
|
||||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||||
|
@ -137,7 +134,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
|
||||
}
|
||||
return IconButton(
|
||||
key: Key('appbar-leading-button'),
|
||||
key: const Key('appbar-leading-button'),
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.menu_arrow,
|
||||
progress: _browseToSelectAnimation,
|
||||
|
@ -147,7 +144,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarTitle() {
|
||||
Widget? _buildAppBarTitle() {
|
||||
if (collection.isBrowsing) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
|
||||
|
@ -197,19 +194,19 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
builder: (context, snapshot) {
|
||||
final canAddShortcuts = snapshot.data ?? false;
|
||||
return PopupMenuButton<CollectionAction>(
|
||||
key: Key('appbar-menu-button'),
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final isNotEmpty = !collection.isEmpty;
|
||||
final hasSelection = collection.selection.isNotEmpty;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: Key('menu-sort'),
|
||||
key: const Key('menu-sort'),
|
||||
value: CollectionAction.sort,
|
||||
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
|
||||
),
|
||||
if (collection.sortFactor == EntrySortFactor.date)
|
||||
PopupMenuItem(
|
||||
key: Key('menu-group'),
|
||||
key: const Key('menu-group'),
|
||||
value: CollectionAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
),
|
||||
|
@ -231,7 +228,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
),
|
||||
],
|
||||
if (collection.isSelecting) ...[
|
||||
PopupMenuDivider(),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.copy,
|
||||
enabled: hasSelection,
|
||||
|
@ -247,7 +244,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
enabled: hasSelection,
|
||||
child: MenuRow(text: context.l10n.collectionActionRefreshMetadata),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectAll,
|
||||
enabled: collection.selection.length < collection.entryCount,
|
||||
|
@ -359,17 +356,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final sortedFilters = List<CollectionFilter>.from(filters)..sort();
|
||||
defaultName = sortedFilters.first.getLabel(context);
|
||||
}
|
||||
final result = await showDialog<Tuple2<AvesEntry, String>>(
|
||||
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||
context: context,
|
||||
builder: (context) => AddShortcutDialog(
|
||||
collection: collection,
|
||||
defaultName: defaultName,
|
||||
defaultName: defaultName ?? '',
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
final coverEntry = result.item1;
|
||||
final name = result.item2;
|
||||
|
||||
if (name == null || name.isEmpty) return;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(AppShortcutService.pin(name, coverEntry, filters));
|
||||
}
|
||||
|
@ -389,7 +387,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: StatsPage.routeName),
|
||||
settings: const RouteSettings(name: StatsPage.routeName),
|
||||
builder: (context) => StatsPage(
|
||||
source: source,
|
||||
parentCollection: collection,
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/app_mode.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
|
@ -22,7 +21,7 @@ import 'package:aves/widgets/common/basic/insets.dart';
|
|||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/item_tracker.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
|
@ -35,29 +34,30 @@ import 'package:provider/provider.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class CollectionGrid extends StatefulWidget {
|
||||
final String settingsRouteKey;
|
||||
final String? settingsRouteKey;
|
||||
|
||||
const CollectionGrid({
|
||||
Key? key,
|
||||
this.settingsRouteKey,
|
||||
});
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_CollectionGridState createState() => _CollectionGridState();
|
||||
}
|
||||
|
||||
class _CollectionGridState extends State<CollectionGrid> {
|
||||
TileExtentController _tileExtentController;
|
||||
TileExtentController? _tileExtentController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_tileExtentController ??= TileExtentController(
|
||||
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName,
|
||||
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
|
||||
columnCountDefault: 4,
|
||||
extentMin: 46,
|
||||
spacing: 0,
|
||||
spacing: 2,
|
||||
);
|
||||
return TileExtentControllerProvider(
|
||||
controller: _tileExtentController,
|
||||
controller: _tileExtentController!,
|
||||
child: _CollectionGridContent(),
|
||||
);
|
||||
}
|
||||
|
@ -75,19 +75,21 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
builder: (context, tileExtent, child) {
|
||||
return ThumbnailTheme(
|
||||
extent: tileExtent,
|
||||
child: Selector<TileExtentController, Tuple2<double, int>>(
|
||||
selector: (context, c) => Tuple2(c.viewportSize.width, c.columnCount),
|
||||
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: scrollableWidth,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
|
@ -99,7 +101,7 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
child: _CollectionSectionedContent(
|
||||
collection: collection,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -119,42 +121,46 @@ class _CollectionSectionedContent extends StatefulWidget {
|
|||
final ScrollController scrollController;
|
||||
|
||||
const _CollectionSectionedContent({
|
||||
@required this.collection,
|
||||
@required this.isScrollingNotifier,
|
||||
@required this.scrollController,
|
||||
required this.collection,
|
||||
required this.isScrollingNotifier,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
_CollectionSectionedContentState createState() => _CollectionSectionedContentState();
|
||||
}
|
||||
|
||||
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
|
||||
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> {
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
@override
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||
@override
|
||||
final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
|
||||
|
||||
@override
|
||||
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
scrollableKey: scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
collection: collection,
|
||||
),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
);
|
||||
|
||||
final scaler = _CollectionScaler(
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
scrollableKey: scrollableKey,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
child: scrollView,
|
||||
);
|
||||
|
||||
|
@ -163,7 +169,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
|
|||
selectable: isMainMode,
|
||||
collection: collection,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
child: scaler,
|
||||
);
|
||||
|
||||
|
@ -177,9 +183,9 @@ class _CollectionScaler extends StatelessWidget {
|
|||
final Widget child;
|
||||
|
||||
const _CollectionScaler({
|
||||
@required this.scrollableKey,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.child,
|
||||
required this.scrollableKey,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -187,15 +193,13 @@ class _CollectionScaler extends StatelessWidget {
|
|||
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
||||
return GridScaleGestureDetector<AvesEntry>(
|
||||
scrollableKey: scrollableKey,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
// painting the thumbnail half-border on top of the grid yields artifacts,
|
||||
// so we use a `foregroundPainter` to cover them instead
|
||||
foregroundPainter: GridPainter(
|
||||
painter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileSpacing,
|
||||
strokeWidth: DecoratedThumbnail.borderWidth * 2,
|
||||
borderWidth: DecoratedThumbnail.borderWidth,
|
||||
borderRadius: Radius.zero,
|
||||
color: DecoratedThumbnail.borderColor,
|
||||
),
|
||||
child: child,
|
||||
|
@ -204,16 +208,11 @@ class _CollectionScaler extends StatelessWidget {
|
|||
extent: extent,
|
||||
child: DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
@ -228,12 +227,12 @@ class _CollectionScrollView extends StatefulWidget {
|
|||
final ScrollController scrollController;
|
||||
|
||||
const _CollectionScrollView({
|
||||
@required this.scrollableKey,
|
||||
@required this.collection,
|
||||
@required this.appBar,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.isScrollingNotifier,
|
||||
@required this.scrollController,
|
||||
required this.scrollableKey,
|
||||
required this.collection,
|
||||
required this.appBar,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.isScrollingNotifier,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -241,7 +240,7 @@ class _CollectionScrollView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
||||
Timer _scrollMonitoringTimer;
|
||||
Timer? _scrollMonitoringTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -316,8 +315,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
|||
primary: true,
|
||||
// workaround to prevent scrolling the app bar away
|
||||
// when there is no content and we use `SliverFillRemaining`
|
||||
physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax * 2),
|
||||
physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : const SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax),
|
||||
slivers: [
|
||||
appBar,
|
||||
collection.isEmpty
|
||||
|
@ -336,7 +335,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
|||
valueListenable: collection.source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
if (sourceState == SourceState.loading) {
|
||||
return SizedBox.shrink();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (collection.filters.any((filter) => filter is FavouriteFilter)) {
|
||||
return EmptyContent(
|
||||
|
|
|
@ -46,7 +46,9 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: CollectionGrid(),
|
||||
child: const CollectionGrid(
|
||||
key: Key('collection-grid'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -13,8 +13,8 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
|||
final double offsetY;
|
||||
|
||||
const CollectionDraggableThumbLabel({
|
||||
@required this.collection,
|
||||
@required this.offsetY,
|
||||
required this.collection,
|
||||
required this.offsetY,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
|||
case EntryGroupFactor.album:
|
||||
return [
|
||||
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||
if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
|
||||
];
|
||||
case EntryGroupFactor.month:
|
||||
case EntryGroupFactor.none:
|
||||
|
@ -40,21 +40,23 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
|||
DraggableThumbLabel.formatDayThumbLabel(context, entry.bestDate),
|
||||
];
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
return [
|
||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||
entry.bestTitle,
|
||||
if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
|
||||
if (entry.bestTitle != null) entry.bestTitle!,
|
||||
];
|
||||
case EntrySortFactor.size:
|
||||
return [
|
||||
formatFilesize(entry.sizeBytes, round: 0),
|
||||
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasMultipleSections(BuildContext context) => context.read<SectionedListLayout<AvesEntry>>().sections.length > 1;
|
||||
|
||||
bool _showAlbumName(BuildContext context, AvesEntry entry) => _hasMultipleSections(context) && entry.directory != null;
|
||||
|
||||
String _getAlbumName(BuildContext context, AvesEntry entry) => context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory!);
|
||||
}
|
||||
|
|
|
@ -3,41 +3,36 @@ import 'dart:async';
|
|||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.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/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.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/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';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
final CollectionLens collection;
|
||||
|
||||
CollectionSource get source => collection.source;
|
||||
|
||||
Set<AvesEntry> get selection => collection.selection;
|
||||
|
||||
EntrySetActionDelegate({
|
||||
@required this.collection,
|
||||
});
|
||||
|
||||
void onEntryActionSelected(BuildContext context, EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.delete:
|
||||
_showDeleteDialog(context);
|
||||
break;
|
||||
case EntryAction.share:
|
||||
AndroidAppService.shareEntries(selection).then((success) {
|
||||
final collection = context.read<CollectionLens>();
|
||||
AndroidAppService.shareEntries(collection.selection).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
|
@ -55,16 +50,25 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
_moveSelection(context, moveType: MoveType.move);
|
||||
break;
|
||||
case CollectionAction.refreshMetadata:
|
||||
source.refreshMetadata(selection);
|
||||
collection.browse();
|
||||
_refreshMetadata(context);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
void _refreshMetadata(BuildContext context) {
|
||||
final collection = context.read<CollectionLens>();
|
||||
collection.source.refreshMetadata(collection.selection);
|
||||
collection.browse();
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final source = collection.source;
|
||||
final selection = collection.selection;
|
||||
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).cast<String>().toSet();
|
||||
if (moveType == MoveType.move) {
|
||||
// check whether moving is possible given OS restrictions,
|
||||
// before asking to pick a destination album
|
||||
|
@ -82,7 +86,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final destinationAlbum = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<String>(
|
||||
settings: RouteSettings(name: AlbumPickPage.routeName),
|
||||
settings: const RouteSettings(name: AlbumPickPage.routeName),
|
||||
builder: (context) => AlbumPickPage(source: source, moveType: moveType),
|
||||
),
|
||||
);
|
||||
|
@ -99,6 +103,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
final copy = moveType == MoveType.copy;
|
||||
final todoCount = todoEntries.length;
|
||||
assert(todoCount > 0);
|
||||
|
||||
source.pauseMonitoring();
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
|
@ -115,6 +121,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
collection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
// cleanup
|
||||
if (moveType == MoveType.move) {
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
}
|
||||
|
||||
final l10n = context.l10n;
|
||||
final movedCount = movedOps.length;
|
||||
if (movedCount < todoCount) {
|
||||
|
@ -122,19 +133,56 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
||||
} else {
|
||||
final count = movedCount;
|
||||
showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count));
|
||||
}
|
||||
|
||||
// cleanup
|
||||
if (moveType == MoveType.move) {
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
showFeedback(
|
||||
context,
|
||||
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
|
||||
SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
var targetCollection = collection;
|
||||
if (collection.filters.any((f) => f is AlbumFilter)) {
|
||||
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
||||
// we could simply add the filter to the current collection
|
||||
// but navigating makes the change less jarring
|
||||
targetCollection = CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
groupFactor: collection.groupFactor,
|
||||
sortFactor: collection.sortFactor,
|
||||
)..addFilter(filter);
|
||||
unawaited(Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) {
|
||||
return CollectionPage(
|
||||
targetCollection,
|
||||
);
|
||||
},
|
||||
),
|
||||
));
|
||||
await Future.delayed(Durations.staggeredAnimationPageTarget);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
final collection = context.read<CollectionLens>();
|
||||
final source = collection.source;
|
||||
final selection = collection.selection;
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).cast<String>().toSet();
|
||||
final todoCount = selection.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
|
|
@ -9,18 +9,18 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
|
||||
final List<CollectionFilter> filters;
|
||||
final bool removable;
|
||||
final FilterCallback onTap;
|
||||
final FilterCallback? onTap;
|
||||
|
||||
FilterBar({
|
||||
Key key,
|
||||
@required Set<CollectionFilter> filters,
|
||||
@required this.removable,
|
||||
Key? key,
|
||||
required Set<CollectionFilter> filters,
|
||||
required this.removable,
|
||||
this.onTap,
|
||||
}) : filters = List<CollectionFilter>.from(filters)..sort(),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
final Size preferredSize = Size.fromHeight(preferredHeight);
|
||||
final Size preferredSize = const Size.fromHeight(preferredHeight);
|
||||
|
||||
@override
|
||||
_FilterBarState createState() => _FilterBarState();
|
||||
|
@ -28,9 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
|
||||
class _FilterBarState extends State<FilterBar> {
|
||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
|
||||
CollectionFilter _userTappedFilter;
|
||||
CollectionFilter? _userTappedFilter;
|
||||
|
||||
FilterCallback get onTap => widget.onTap;
|
||||
FilterCallback? get onTap => widget.onTap;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilterBar oldWidget) {
|
||||
|
@ -46,7 +46,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
// only animate item removal when triggered by a user interaction with the chip,
|
||||
// not from automatic chip replacement following chip selection
|
||||
final animate = _userTappedFilter == filter;
|
||||
listState.removeItem(
|
||||
listState!.removeItem(
|
||||
index,
|
||||
animate
|
||||
? (context, animation) {
|
||||
|
@ -69,7 +69,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
});
|
||||
added.forEach((filter) {
|
||||
final index = current.indexOf(filter);
|
||||
listState.insertItem(
|
||||
listState!.insertItem(
|
||||
index,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
|
@ -92,10 +92,10 @@ class _FilterBarState extends State<FilterBar> {
|
|||
key: _animatedListKey,
|
||||
initialItemCount: widget.filters.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
itemBuilder: (context, index, animation) {
|
||||
if (index >= widget.filters.length) return null;
|
||||
if (index >= widget.filters.length) return const SizedBox();
|
||||
return _buildChip(widget.filters.toList()[index]);
|
||||
},
|
||||
),
|
||||
|
@ -105,7 +105,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
|
||||
Padding _buildChip(CollectionFilter filter) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Center(
|
||||
child: AvesFilterChip(
|
||||
key: ValueKey(filter),
|
||||
|
@ -115,7 +115,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
onTap: onTap != null
|
||||
? (filter) {
|
||||
_userTappedFilter = filter;
|
||||
onTap(filter);
|
||||
onTap!(filter);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
|
|
@ -2,37 +2,43 @@ import 'package:aves/model/source/collection_source.dart';
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AlbumSectionHeader extends StatelessWidget {
|
||||
final String directory, albumName;
|
||||
final String? directory, albumName;
|
||||
|
||||
const AlbumSectionHeader({
|
||||
Key key,
|
||||
@required this.directory,
|
||||
@required this.albumName,
|
||||
Key? key,
|
||||
required this.directory,
|
||||
required this.albumName,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var albumIcon = IconUtils.getAlbumIcon(context: context, album: directory);
|
||||
if (albumIcon != null) {
|
||||
albumIcon = Material(
|
||||
type: MaterialType.circle,
|
||||
elevation: 3,
|
||||
color: Colors.transparent,
|
||||
shadowColor: Colors.black,
|
||||
child: albumIcon,
|
||||
);
|
||||
Widget? albumIcon;
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return SectionHeader(
|
||||
sectionKey: EntryAlbumSectionKey(directory),
|
||||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(directory)
|
||||
? Icon(
|
||||
title: albumName ?? context.l10n.sectionUnknown,
|
||||
trailing: directory != null && androidFileUtils.isOnRemovableStorage(directory!)
|
||||
? const Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: Color(0xFF757575),
|
||||
|
@ -42,7 +48,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
|
||||
final directory = sectionKey.directory;
|
||||
final directory = sectionKey.directory ?? context.l10n.sectionUnknown;
|
||||
return SectionHeader.getPreferredHeight(
|
||||
context: context,
|
||||
maxWidth: maxWidth,
|
||||
|
|
|
@ -15,10 +15,10 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
final double height;
|
||||
|
||||
const CollectionSectionHeader({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.height,
|
||||
Key? key,
|
||||
required this.collection,
|
||||
required this.sectionKey,
|
||||
required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -29,10 +29,10 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
Widget? _buildHeader(BuildContext context) {
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
|
@ -60,7 +60,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
return AlbumSectionHeader(
|
||||
key: ValueKey(sectionKey),
|
||||
directory: directory,
|
||||
albumName: source.getAlbumDisplayName(context, directory),
|
||||
albumName: directory != null ? source.getAlbumDisplayName(context, directory) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,11 +6,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
|
||||
class DaySectionHeader extends StatelessWidget {
|
||||
final DateTime date;
|
||||
final DateTime? date;
|
||||
|
||||
const DaySectionHeader({
|
||||
Key key,
|
||||
@required this.date,
|
||||
Key? key,
|
||||
required this.date,
|
||||
}) : super(key: key);
|
||||
|
||||
// Examples (en_US):
|
||||
|
@ -33,7 +33,7 @@ class DaySectionHeader extends StatelessWidget {
|
|||
// `MEd`: `1. 26. (화)`
|
||||
// `yMEd`: `2021. 1. 26. (화)`
|
||||
|
||||
static String _formatDate(BuildContext context, DateTime date) {
|
||||
static String _formatDate(BuildContext context, DateTime? date) {
|
||||
final l10n = context.l10n;
|
||||
if (date == null) return l10n.sectionUnknown;
|
||||
if (date.isToday) return l10n.dateToday;
|
||||
|
@ -53,14 +53,14 @@ class DaySectionHeader extends StatelessWidget {
|
|||
}
|
||||
|
||||
class MonthSectionHeader extends StatelessWidget {
|
||||
final DateTime date;
|
||||
final DateTime? date;
|
||||
|
||||
const MonthSectionHeader({
|
||||
Key key,
|
||||
@required this.date,
|
||||
Key? key,
|
||||
required this.date,
|
||||
}) : super(key: key);
|
||||
|
||||
static String _formatDate(BuildContext context, DateTime date) {
|
||||
static String _formatDate(BuildContext context, DateTime? date) {
|
||||
final l10n = context.l10n;
|
||||
if (date == null) return l10n.sectionUnknown;
|
||||
if (date.isThisMonth) return l10n.dateThisMonth;
|
||||
|
|
|
@ -3,23 +3,24 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
|
||||
final CollectionLens collection;
|
||||
|
||||
const SectionedEntryListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(AvesEntry entry) tileBuilder,
|
||||
@required Duration tileAnimationDelay,
|
||||
@required Widget child,
|
||||
required this.collection,
|
||||
required double scrollableWidth,
|
||||
required int columnCount,
|
||||
required double spacing,
|
||||
required double tileExtent,
|
||||
required Widget Function(AvesEntry entry) tileBuilder,
|
||||
required Duration tileAnimationDelay,
|
||||
required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
|
|
|
@ -19,10 +19,10 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
|||
|
||||
const GridSelectionGestureDetector({
|
||||
this.selectable = true,
|
||||
@required this.collection,
|
||||
@required this.scrollController,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.child,
|
||||
required this.collection,
|
||||
required this.scrollController,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -30,16 +30,16 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||
bool _pressing = false, _selecting;
|
||||
int _fromIndex, _lastToIndex;
|
||||
Offset _localPosition;
|
||||
EdgeInsets _scrollableInsets;
|
||||
double _scrollSpeedFactor;
|
||||
Timer _updateTimer;
|
||||
bool _pressing = false, _selecting = false;
|
||||
late int _fromIndex, _lastToIndex;
|
||||
late Offset _localPosition;
|
||||
late EdgeInsets _scrollableInsets;
|
||||
late double _scrollSpeedFactor;
|
||||
Timer? _updateTimer;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
List<AvesEntry> get entries => collection.sortedEntries;
|
||||
List<AvesEntry > get entries => collection.sortedEntries;
|
||||
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
|
@ -102,7 +102,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
}
|
||||
|
||||
final toEntry = _getEntryAt(_localPosition);
|
||||
_toggleSelectionToIndex(entries.indexOf(toEntry));
|
||||
if (toEntry != null) {
|
||||
_toggleSelectionToIndex(entries.indexOf(toEntry));
|
||||
}
|
||||
}
|
||||
|
||||
void _setScrollSpeed(double speedFactor) {
|
||||
|
@ -131,7 +133,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
}
|
||||
}
|
||||
|
||||
AvesEntry _getEntryAt(Offset localPosition) {
|
||||
AvesEntry? _getEntryAt(Offset localPosition) {
|
||||
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
||||
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||
// so we use custom layout computation instead to find the entry.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue