unsound null safety
This commit is contained in:
parent
51bfb3cd04
commit
140ba900ce
252 changed files with 2821 additions and 2731 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -31,4 +31,4 @@ jobs:
|
|||
run: flutter analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: flutter test
|
||||
run: flutter test --no-sound-null-safety
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
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) {
|
||||
|
@ -43,7 +43,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 +66,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());
|
||||
}
|
||||
|
|
170
lib/main.dart
170
lib/main.dart
|
@ -1,31 +1,9 @@
|
|||
// @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
|
||||
|
@ -41,147 +19,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,17 +11,17 @@ import 'package:version/version.dart';
|
|||
abstract class AvesAvailability {
|
||||
void onResume();
|
||||
|
||||
Future<bool> get isConnected;
|
||||
Future<bool > get isConnected;
|
||||
|
||||
Future<bool> get hasPlayServices;
|
||||
Future<bool > get hasPlayServices;
|
||||
|
||||
Future<bool> get canLocatePlaces;
|
||||
|
||||
Future<bool> get isNewVersionAvailable;
|
||||
Future<bool > get isNewVersionAvailable;
|
||||
}
|
||||
|
||||
class LiveAvesAvailability implements AvesAvailability {
|
||||
bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
|
||||
bool? _isConnected, _hasPlayServices, _isNewVersionAvailable;
|
||||
|
||||
LiveAvesAvailability() {
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
|
||||
|
@ -31,11 +31,11 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
void onResume() => _isConnected = null;
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected);
|
||||
Future<bool > get isConnected async {
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected!);
|
||||
final result = await (Connectivity().checkConnectivity());
|
||||
_updateConnectivityFromResult(result);
|
||||
return _isConnected;
|
||||
return _isConnected!;
|
||||
}
|
||||
|
||||
void _updateConnectivityFromResult(ConnectivityResult result) {
|
||||
|
@ -47,12 +47,12 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<bool> get hasPlayServices async {
|
||||
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
|
||||
Future<bool > get hasPlayServices async {
|
||||
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
|
||||
|
@ -60,28 +60,28 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
||||
|
||||
@override
|
||||
Future<bool> get isNewVersionAvailable async {
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);
|
||||
Future<bool > get isNewVersionAvailable async {
|
||||
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,24 @@ class AvesEntry {
|
|||
return isRotated ? Size(h, w) : Size(w, h);
|
||||
}
|
||||
|
||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||
int get megaPixels => (width * height / 1000000).round();
|
||||
|
||||
DateTime _bestDate;
|
||||
DateTime? _bestDate;
|
||||
|
||||
DateTime get 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 +317,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 +425,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 +443,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 +456,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 +494,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 +550,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 +580,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 +594,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 +627,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);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,12 +8,12 @@ class EntryCache {
|
|||
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,7 +29,7 @@ class EntryCache {
|
|||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
dateModifiedSecs: dateModifiedSecs ?? 0,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
)).evict();
|
||||
|
@ -42,7 +42,7 @@ class EntryCache {
|
|||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
dateModifiedSecs: dateModifiedSecs ?? 0,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
extent: extent,
|
||||
|
|
|
@ -5,7 +5,6 @@ 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:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
|
@ -30,11 +29,11 @@ extension ExtraAvesEntry on AvesEntry {
|
|||
);
|
||||
}
|
||||
|
||||
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,7 +55,7 @@ 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);
|
||||
|
|
|
@ -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,
|
||||
) ??
|
||||
|
@ -52,26 +52,25 @@ class AlbumFilter extends CollectionFilter {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Color> color(BuildContext context) {
|
||||
Future<Color > color(BuildContext context) {
|
||||
// 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]!);
|
||||
|
||||
final packageName = androidFileUtils.getAlbumAppPackageName(album);
|
||||
if (packageName != null) {
|
||||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(
|
||||
packageName: androidFileUtils.getAlbumAppPackageName(album),
|
||||
size: 24,
|
||||
),
|
||||
).then((palette) {
|
||||
final color = palette.dominantColor?.color ?? super.color(context);
|
||||
AppIconImage(packageName: packageName, size: 24),
|
||||
).then((palette) async {
|
||||
final color = palette.dominantColor?.color ?? (await super.color(context));
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
} else {
|
||||
return super.color(context);
|
||||
}
|
||||
}
|
||||
return super.color(context);
|
||||
}
|
||||
|
||||
@override
|
||||
String get category => type;
|
||||
|
|
|
@ -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,7 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class HighlightInfo extends ChangeNotifier {
|
||||
Object _item;
|
||||
Object? _item;
|
||||
|
||||
void set(Object item) {
|
||||
if (_item == item) return;
|
||||
|
@ -9,7 +9,7 @@ class HighlightInfo extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Object clear() {
|
||||
Object? clear() {
|
||||
if (_item == null) return null;
|
||||
final item = _item;
|
||||
_item = null;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -18,26 +18,26 @@ import 'enums.dart';
|
|||
|
||||
class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
||||
final CollectionSource source;
|
||||
final Set<CollectionFilter> filters;
|
||||
final Set<CollectionFilter > filters;
|
||||
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({});
|
||||
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;
|
||||
|
@ -61,7 +61,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
_subscriptions = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -70,11 +69,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;
|
||||
List<AvesEntry > get sortedEntries {
|
||||
_sortedEntries ??= List.of(sections.entries.expand((kv) => kv.value));
|
||||
return _sortedEntries!;
|
||||
}
|
||||
|
||||
bool get showHeaders {
|
||||
|
@ -90,7 +89,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 +98,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 +155,19 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
break;
|
||||
case EntryGroupFactor.none:
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(null, _filteredSortedEntries),
|
||||
MapEntry(SectionKey(), _filteredSortedEntries),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(null, _filteredSortedEntries),
|
||||
MapEntry(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 +183,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_applyGroup();
|
||||
}
|
||||
|
||||
void onEntryAdded(Set<AvesEntry> entries) {
|
||||
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);
|
||||
|
@ -159,7 +160,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 +178,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);
|
||||
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,
|
||||
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 +258,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 +300,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
class EntryAddedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
final Set<AvesEntry>? entries;
|
||||
|
||||
const EntryAddedEvent([this.entries]);
|
||||
}
|
||||
|
@ -324,5 +327,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 newAddresses = <AddressDetails>[];
|
||||
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,21 +82,23 @@ 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;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newAddresses = <AddressDetails>[];
|
||||
final newAddresses = <AddressDetails >[];
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
|
@ -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,6 +28,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
await favourites.init();
|
||||
await covers.init();
|
||||
final currentTimeZone = await timeService.getDefaultTimeZone();
|
||||
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
|
||||
|
@ -35,6 +37,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
await metadataDb.clearMetadataEntries();
|
||||
settings.catalogTimeZone = currentTimeZone;
|
||||
}
|
||||
}
|
||||
await loadDates(); // 100ms for 5400 entries
|
||||
_initialized = true;
|
||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||
|
@ -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,8 +133,8 @@ 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 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;
|
||||
|
@ -139,11 +142,13 @@ class MediaStoreSource extends CollectionSource {
|
|||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
}).where((kv) => kv != null));
|
||||
})
|
||||
.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,8 +7,7 @@ class BrandColors {
|
|||
static const Color android = Color(0xFF3DDC84);
|
||||
static const Color flutter = Color(0xFF47D1FD);
|
||||
|
||||
static Color get(String text) {
|
||||
if (text != null) {
|
||||
static Color? get(String text) {
|
||||
switch (text.toLowerCase()) {
|
||||
case 'after effects':
|
||||
return adobeAfterEffects;
|
||||
|
@ -18,7 +17,6 @@ class BrandColors {
|
|||
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,31 @@ 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 +43,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 +66,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 +111,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 +128,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
Future<Uint8List> getSvg(
|
||||
String uri,
|
||||
String mimeType, {
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
}) =>
|
||||
getImage(
|
||||
uri,
|
||||
|
@ -141,11 +144,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 +158,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
'isFlipped': isFlipped,
|
||||
'pageId': pageId,
|
||||
}).listen(
|
||||
(data) {
|
||||
|
@ -182,7 +185,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 +197,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 +216,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 +229,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 +256,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 +283,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 +301,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 +320,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 +337,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 +352,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 +367,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 +382,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 +405,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 +414,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}');
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,7 +55,6 @@ 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;
|
||||
|
@ -61,19 +62,18 @@ class AndroidFileUtils {
|
|||
|
||||
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');
|
||||
}
|
||||
|
|
|
@ -310,9 +310,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,11 +15,11 @@ 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());
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ class AppReference extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AppReferenceState extends State<AppReference> {
|
||||
Future<PackageInfo> _packageInfoLoader;
|
||||
late Future<PackageInfo> _packageInfoLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -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,7 +59,7 @@ 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(
|
||||
|
@ -68,7 +68,7 @@ class _AppReferenceState extends State<AppReference> {
|
|||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 4),
|
||||
child: FlutterLogo(
|
||||
size: style.fontSize * 1.25,
|
||||
size: style.fontSize! * 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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() {
|
||||
|
@ -118,8 +118,8 @@ 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),
|
||||
|
|
|
@ -11,7 +11,7 @@ class AboutUpdate extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AboutUpdateState extends State<AboutUpdate> {
|
||||
Future<bool> _updateChecker;
|
||||
late Future<bool> _updateChecker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
171
lib/widgets/aves_app.dart
Normal file
171
lib/widgets/aves_app.dart
Normal file
|
@ -0,0 +1,171 @@
|
|||
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 = 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
late EntrySetActionDelegate _actionDelegate;
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -68,7 +68,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
_canAddShortcutsLoader = AppShortcutService.canPin();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight());
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -127,8 +127,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;
|
||||
|
@ -147,7 +147,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);
|
||||
|
@ -359,17 +359,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));
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class CollectionGrid extends StatefulWidget {
|
||||
final String settingsRouteKey;
|
||||
final String? settingsRouteKey;
|
||||
|
||||
const CollectionGrid({
|
||||
this.settingsRouteKey,
|
||||
|
@ -46,18 +46,18 @@ class CollectionGrid extends StatefulWidget {
|
|||
}
|
||||
|
||||
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,
|
||||
);
|
||||
return TileExtentControllerProvider(
|
||||
controller: _tileExtentController,
|
||||
controller: _tileExtentController!,
|
||||
child: _CollectionGridContent(),
|
||||
);
|
||||
}
|
||||
|
@ -99,7 +99,7 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
child: _CollectionSectionedContent(
|
||||
collection: collection,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -119,9 +119,9 @@ 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
|
||||
|
@ -177,9 +177,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
|
||||
|
@ -228,12 +228,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 +241,7 @@ class _CollectionScrollView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
||||
Timer _scrollMonitoringTimer;
|
||||
Timer? _scrollMonitoringTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
|
@ -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!);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ 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:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -28,7 +27,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
Set<AvesEntry> get selection => collection.selection;
|
||||
|
||||
EntrySetActionDelegate({
|
||||
@required this.collection,
|
||||
required this.collection,
|
||||
});
|
||||
|
||||
void onEntryActionSelected(BuildContext context, EntryAction action) {
|
||||
|
@ -63,8 +62,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
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
|
||||
|
@ -134,7 +133,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
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,12 +9,12 @@ 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);
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -95,7 +95,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
physics: BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
itemBuilder: (context, index, animation) {
|
||||
if (index >= widget.filters.length) return null;
|
||||
if (index >= widget.filters.length) return SizedBox();
|
||||
return _buildChip(widget.filters.toList()[index]);
|
||||
},
|
||||
),
|
||||
|
@ -115,7 +115,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
onTap: onTap != null
|
||||
? (filter) {
|
||||
_userTappedFilter = filter;
|
||||
onTap(filter);
|
||||
onTap!(filter);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
|
|
@ -2,22 +2,25 @@ 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);
|
||||
Widget? albumIcon;
|
||||
if (directory != null) {
|
||||
albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!);
|
||||
if (albumIcon != null) {
|
||||
albumIcon = Material(
|
||||
type: MaterialType.circle,
|
||||
|
@ -27,11 +30,12 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
child: albumIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
return SectionHeader(
|
||||
sectionKey: EntryAlbumSectionKey(directory),
|
||||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(directory)
|
||||
title: albumName ?? context.l10n.sectionUnknown,
|
||||
trailing: directory != null && androidFileUtils.isOnRemovableStorage(directory!)
|
||||
? Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
|
@ -42,7 +46,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
|
||||
|
@ -32,7 +32,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
: 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,20 +3,19 @@ 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 tileExtent,
|
||||
required Widget Function(AvesEntry entry) tileBuilder,
|
||||
required Duration tileAnimationDelay,
|
||||
required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
|
|
|
@ -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,8 +102,10 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
}
|
||||
|
||||
final toEntry = _getEntryAt(_localPosition);
|
||||
if (toEntry != null) {
|
||||
_toggleSelectionToIndex(entries.indexOf(toEntry));
|
||||
}
|
||||
}
|
||||
|
||||
void _setScrollSpeed(double speedFactor) {
|
||||
if (speedFactor == _scrollSpeedFactor) return;
|
||||
|
@ -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.
|
||||
|
|
|
@ -13,13 +13,13 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
final CollectionLens collection;
|
||||
final AvesEntry entry;
|
||||
final double tileExtent;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
final ValueNotifier<bool>? isScrollingNotifier;
|
||||
|
||||
const InteractiveThumbnail({
|
||||
Key key,
|
||||
this.collection,
|
||||
@required this.entry,
|
||||
@required this.tileExtent,
|
||||
Key? key,
|
||||
required this.collection,
|
||||
required this.entry,
|
||||
required this.tileExtent,
|
||||
this.isScrollingNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
|
|
|
@ -8,17 +8,17 @@ import 'package:flutter/material.dart';
|
|||
class DecoratedThumbnail extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final CollectionLens collection;
|
||||
final ValueNotifier<bool> cancellableNotifier;
|
||||
final CollectionLens? collection;
|
||||
final ValueNotifier<bool>? cancellableNotifier;
|
||||
final bool selectable, highlightable;
|
||||
|
||||
static final Color borderColor = Colors.grey.shade700;
|
||||
static const double borderWidth = .5;
|
||||
|
||||
const DecoratedThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
this.collection,
|
||||
this.cancellableNotifier,
|
||||
this.selectable = true,
|
||||
|
|
|
@ -13,9 +13,9 @@ class ErrorThumbnail extends StatefulWidget {
|
|||
final String tooltip;
|
||||
|
||||
const ErrorThumbnail({
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
@required this.tooltip,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
required this.tooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -23,7 +23,7 @@ class ErrorThumbnail extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
||||
Future<bool> _exists;
|
||||
late Future<bool> _exists;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
|
@ -32,7 +32,7 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_exists = entry.path != null ? File(entry.path).exists() : SynchronousFuture(true);
|
||||
_exists = entry.path != null ? File(entry.path!).exists() : SynchronousFuture(true);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -42,7 +42,7 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
|||
future: _exists,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox();
|
||||
final exists = snapshot.data;
|
||||
final exists = snapshot.data!;
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.black,
|
||||
|
|
|
@ -17,9 +17,9 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
final double extent;
|
||||
|
||||
const ThumbnailEntryOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -56,9 +56,9 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
static const duration = Durations.thumbnailOverlayAnimation;
|
||||
|
||||
const ThumbnailSelectionOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -113,9 +113,9 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
|
|||
final double extent;
|
||||
|
||||
const ThumbnailHighlightOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
|
@ -10,13 +10,13 @@ import 'package:flutter/material.dart';
|
|||
class RasterImageThumbnail extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final ValueNotifier<bool> cancellableNotifier;
|
||||
final Object heroTag;
|
||||
final ValueNotifier<bool>? cancellableNotifier;
|
||||
final Object? heroTag;
|
||||
|
||||
const RasterImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
this.cancellableNotifier,
|
||||
this.heroTag,
|
||||
}) : super(key: key);
|
||||
|
@ -26,7 +26,7 @@ class RasterImageThumbnail extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||
ThumbnailProvider? _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
|
@ -85,7 +85,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
|
||||
final fastImage = Image(
|
||||
key: ValueKey('LQ'),
|
||||
image: _fastThumbnailProvider,
|
||||
image: _fastThumbnailProvider!,
|
||||
errorBuilder: _buildError,
|
||||
width: extent,
|
||||
height: extent,
|
||||
|
@ -95,7 +95,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
? fastImage
|
||||
: Image(
|
||||
key: ValueKey('HQ'),
|
||||
image: _sizedThumbnailProvider,
|
||||
image: _sizedThumbnailProvider!,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
return AnimatedSwitcher(
|
||||
|
@ -123,7 +123,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
);
|
||||
return widget.heroTag != null
|
||||
? Hero(
|
||||
tag: widget.heroTag,
|
||||
tag: widget.heroTag!,
|
||||
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
||||
return TransitionImage(
|
||||
image: entry.getBestThumbnail(extent),
|
||||
|
@ -136,7 +136,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
|||
: image;
|
||||
}
|
||||
|
||||
Widget _buildError(BuildContext context, Object error, StackTrace stackTrace) => ErrorThumbnail(
|
||||
Widget _buildError(BuildContext context, Object error, StackTrace? stackTrace) => ErrorThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
tooltip: error.toString(),
|
||||
|
|
|
@ -6,13 +6,13 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class ThumbnailTheme extends StatelessWidget {
|
||||
final double extent;
|
||||
final bool showLocation;
|
||||
final bool? showLocation;
|
||||
final Widget child;
|
||||
|
||||
const ThumbnailTheme({
|
||||
@required this.extent,
|
||||
required this.extent,
|
||||
this.showLocation,
|
||||
@required this.child,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -39,10 +39,10 @@ class ThumbnailThemeData {
|
|||
final bool showLocation, showRaw, showVideoDuration;
|
||||
|
||||
const ThumbnailThemeData({
|
||||
@required this.iconSize,
|
||||
@required this.fontSize,
|
||||
@required this.showLocation,
|
||||
@required this.showRaw,
|
||||
@required this.showVideoDuration,
|
||||
required this.iconSize,
|
||||
required this.fontSize,
|
||||
required this.showLocation,
|
||||
required this.showRaw,
|
||||
required this.showVideoDuration,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,12 +11,12 @@ import 'package:provider/provider.dart';
|
|||
class VectorImageThumbnail extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final Object heroTag;
|
||||
final Object? heroTag;
|
||||
|
||||
const VectorImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
this.heroTag,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -31,7 +31,7 @@ class VectorImageThumbnail extends StatelessWidget {
|
|||
builder: (context, constraints) {
|
||||
final availableSize = constraints.biggest;
|
||||
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||
final offset = fitSize / 2 - availableSize / 2;
|
||||
final offset = (fitSize / 2 - availableSize / 2) as Offset;
|
||||
final child = CustomPaint(
|
||||
painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
|
||||
child: SvgPicture(
|
||||
|
@ -66,7 +66,7 @@ class VectorImageThumbnail extends StatelessWidget {
|
|||
);
|
||||
return heroTag != null
|
||||
? Hero(
|
||||
tag: heroTag,
|
||||
tag: heroTag!,
|
||||
transitionOnUserGestures: true,
|
||||
child: child,
|
||||
)
|
||||
|
|
|
@ -21,12 +21,12 @@ mixin FeedbackMixin {
|
|||
// report overlay for multiple operations
|
||||
|
||||
void showOpReport<T>({
|
||||
@required BuildContext context,
|
||||
@required Stream<T> opStream,
|
||||
@required int itemCount,
|
||||
void Function(Set<T> processed) onDone,
|
||||
required BuildContext context,
|
||||
required Stream<T> opStream,
|
||||
required int itemCount,
|
||||
void Function(Set<T> processed)? onDone,
|
||||
}) {
|
||||
OverlayEntry _opReportOverlayEntry;
|
||||
late OverlayEntry _opReportOverlayEntry;
|
||||
_opReportOverlayEntry = OverlayEntry(
|
||||
builder: (context) => ReportOverlay<T>(
|
||||
opStream: opStream,
|
||||
|
@ -37,7 +37,7 @@ mixin FeedbackMixin {
|
|||
},
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||
Overlay.of(context)!.insert(_opReportOverlayEntry);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,9 +47,9 @@ class ReportOverlay<T> extends StatefulWidget {
|
|||
final void Function(Set<T> processed) onDone;
|
||||
|
||||
const ReportOverlay({
|
||||
@required this.opStream,
|
||||
@required this.itemCount,
|
||||
@required this.onDone,
|
||||
required this.opStream,
|
||||
required this.itemCount,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -58,8 +58,8 @@ class ReportOverlay<T> extends StatefulWidget {
|
|||
|
||||
class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin {
|
||||
final processed = <T>{};
|
||||
AnimationController _animationController;
|
||||
Animation<double> _animation;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
Stream<T> get opStream => widget.opStream;
|
||||
|
||||
|
|
|
@ -3,21 +3,21 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
mixin PermissionAwareMixin {
|
||||
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
|
||||
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
||||
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).cast<String >().toSet());
|
||||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
while (true) {
|
||||
final dirs = await storageService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs == null) return false;
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null);
|
||||
final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains);
|
||||
if (restrictedInaccessibleDir != null) {
|
||||
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
|
||||
return false;
|
||||
|
@ -57,7 +57,7 @@ mixin PermissionAwareMixin {
|
|||
}
|
||||
}
|
||||
|
||||
Future<bool> showRestrictedDirectoryDialog(BuildContext context, VolumeRelativeDirectory dir) {
|
||||
Future<bool?> showRestrictedDirectoryDialog(BuildContext context, VolumeRelativeDirectory dir) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
|
|
|
@ -15,14 +15,19 @@ import 'package:flutter/widgets.dart';
|
|||
mixin SizeAwareMixin {
|
||||
Future<bool> checkFreeSpaceForMove(
|
||||
BuildContext context,
|
||||
Set<AvesEntry> selection,
|
||||
Set<AvesEntry > selection,
|
||||
String destinationAlbum,
|
||||
MoveType moveType,
|
||||
) async {
|
||||
// assume we have enough space if we cannot find the volume or its remaining free space
|
||||
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||
if (destinationVolume == null) return true;
|
||||
|
||||
final free = await storageService.getFreeSpace(destinationVolume);
|
||||
int needed;
|
||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||
if (free == null) return true;
|
||||
|
||||
late int needed;
|
||||
int sumSize(sum, entry) => sum + entry.sizeBytes ?? 0;
|
||||
switch (moveType) {
|
||||
case MoveType.copy:
|
||||
case MoveType.export:
|
||||
|
@ -30,11 +35,11 @@ mixin SizeAwareMixin {
|
|||
break;
|
||||
case MoveType.move:
|
||||
// when moving, we only need space for the entries that are not already on the destination volume
|
||||
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||
final byVolume = Map.fromEntries(groupBy<AvesEntry, StorageVolume?>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)).entries.where((kv) => kv.key != null).cast<MapEntry<StorageVolume, List<AvesEntry>>>());
|
||||
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume]!.fold(0, sumSize));
|
||||
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes ?? 0));
|
||||
needed = max(fromOtherVolumes, largestSingle);
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -9,9 +9,9 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
|
|||
final CollectionSource source;
|
||||
|
||||
const SourceStateAwareAppBarTitle({
|
||||
Key key,
|
||||
@required this.title,
|
||||
@required this.source,
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.source,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -49,11 +49,11 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
|
|||
class SourceStateSubtitle extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
|
||||
const SourceStateSubtitle({@required this.source});
|
||||
const SourceStateSubtitle({required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String subtitle;
|
||||
String? subtitle;
|
||||
switch (source.stateNotifier.value) {
|
||||
case SourceState.loading:
|
||||
subtitle = context.l10n.sourceStateLoading;
|
||||
|
@ -79,12 +79,12 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink();
|
||||
final progress = snapshot.data;
|
||||
final progress = snapshot.data!;
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
style: subtitleStyle!.copyWith(color: Colors.white30),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class InteractiveAppBarTitle extends StatelessWidget {
|
||||
final GestureTapCallback onTap;
|
||||
final GestureTapCallback? onTap;
|
||||
final Widget child;
|
||||
|
||||
const InteractiveAppBarTitle({
|
||||
this.onTap,
|
||||
@required this.child,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
@ -14,7 +14,7 @@ class AvesHighlightView extends StatelessWidget {
|
|||
/// It is recommended to give it a value for performance
|
||||
///
|
||||
/// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages)
|
||||
final String language;
|
||||
final String? language;
|
||||
|
||||
/// Highlight theme
|
||||
///
|
||||
|
@ -22,12 +22,12 @@ class AvesHighlightView extends StatelessWidget {
|
|||
final Map<String, TextStyle> theme;
|
||||
|
||||
/// Padding
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Text styles
|
||||
///
|
||||
/// Specify text styles such as font family and font size
|
||||
final TextStyle textStyle;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
AvesHighlightView(
|
||||
String input, {
|
||||
|
@ -45,16 +45,16 @@ class AvesHighlightView extends StatelessWidget {
|
|||
|
||||
void _traverse(Node node) {
|
||||
if (node.value != null) {
|
||||
currentSpans.add(node.className == null ? TextSpan(text: node.value) : TextSpan(text: node.value, style: theme[node.className]));
|
||||
currentSpans.add(node.className == null ? TextSpan(text: node.value) : TextSpan(text: node.value, style: theme[node.className!]));
|
||||
} else if (node.children != null) {
|
||||
final tmp = <TextSpan>[];
|
||||
currentSpans.add(TextSpan(children: tmp, style: theme[node.className]));
|
||||
currentSpans.add(TextSpan(children: tmp, style: theme[node.className!]));
|
||||
stack.add(currentSpans);
|
||||
currentSpans = tmp;
|
||||
|
||||
node.children.forEach((n) {
|
||||
node.children!.forEach((n) {
|
||||
_traverse(n);
|
||||
if (n == node.children.last) {
|
||||
if (n == node.children!.last) {
|
||||
currentSpans = stack.isEmpty ? spans : stack.removeLast();
|
||||
}
|
||||
});
|
||||
|
@ -93,7 +93,7 @@ class AvesHighlightView extends StatelessWidget {
|
|||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: _textStyle,
|
||||
children: _convert(highlight.parse(source, language: language).nodes),
|
||||
children: _convert(highlight.parse(source, language: language).nodes!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -18,7 +18,7 @@ typedef ScrollThumbBuilder = Widget Function(
|
|||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Widget labelText,
|
||||
Widget? labelText,
|
||||
});
|
||||
|
||||
/// Build a Text widget using the current scroll offset
|
||||
|
@ -37,7 +37,7 @@ class DraggableScrollbar extends StatefulWidget {
|
|||
final ScrollThumbBuilder scrollThumbBuilder;
|
||||
|
||||
/// The amount of padding that should surround the thumb
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Determines how quickly the scrollbar will animate in and out
|
||||
final Duration scrollbarAnimationDuration;
|
||||
|
@ -46,7 +46,7 @@ class DraggableScrollbar extends StatefulWidget {
|
|||
final Duration scrollbarTimeToFade;
|
||||
|
||||
/// Build a Text widget from the current offset in the BoxScrollView
|
||||
final LabelTextBuilder labelTextBuilder;
|
||||
final LabelTextBuilder? labelTextBuilder;
|
||||
|
||||
/// The ScrollController for the BoxScrollView
|
||||
final ScrollController controller;
|
||||
|
@ -55,30 +55,28 @@ class DraggableScrollbar extends StatefulWidget {
|
|||
final ScrollView child;
|
||||
|
||||
DraggableScrollbar({
|
||||
Key key,
|
||||
@required this.backgroundColor,
|
||||
@required this.scrollThumbHeight,
|
||||
@required this.scrollThumbBuilder,
|
||||
@required this.controller,
|
||||
Key? key,
|
||||
required this.backgroundColor,
|
||||
required this.scrollThumbHeight,
|
||||
required this.scrollThumbBuilder,
|
||||
required this.controller,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 1000),
|
||||
this.labelTextBuilder,
|
||||
@required this.child,
|
||||
}) : assert(controller != null),
|
||||
assert(scrollThumbBuilder != null),
|
||||
assert(child.scrollDirection == Axis.vertical),
|
||||
required this.child,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
_DraggableScrollbarState createState() => _DraggableScrollbarState();
|
||||
|
||||
static Widget buildScrollThumbAndLabel({
|
||||
@required Widget scrollThumb,
|
||||
@required Color backgroundColor,
|
||||
@required Animation<double> thumbAnimation,
|
||||
@required Animation<double> labelAnimation,
|
||||
@required Widget labelText,
|
||||
required Widget scrollThumb,
|
||||
required Color backgroundColor,
|
||||
required Animation<double> thumbAnimation,
|
||||
required Animation<double> labelAnimation,
|
||||
required Widget? labelText,
|
||||
}) {
|
||||
final scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
|
@ -108,10 +106,10 @@ class ScrollLabel extends StatelessWidget {
|
|||
final Widget child;
|
||||
|
||||
const ScrollLabel({
|
||||
Key key,
|
||||
@required this.child,
|
||||
@required this.animation,
|
||||
@required this.backgroundColor,
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.animation,
|
||||
required this.backgroundColor,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -134,13 +132,13 @@ class ScrollLabel extends StatelessWidget {
|
|||
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
|
||||
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0);
|
||||
bool _isDragInProcess = false;
|
||||
Offset _longPressLastGlobalPosition;
|
||||
late Offset _longPressLastGlobalPosition;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
AnimationController _labelAnimationController;
|
||||
Animation<double> _labelAnimation;
|
||||
Timer _fadeoutTimer;
|
||||
late AnimationController _thumbAnimationController;
|
||||
late Animation<double> _thumbAnimation;
|
||||
late AnimationController _labelAnimationController;
|
||||
late Animation<double> _labelAnimation;
|
||||
Timer? _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -177,7 +175,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
|
||||
ScrollController get controller => widget.controller;
|
||||
|
||||
double get thumbMaxScrollExtent => context.size.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0);
|
||||
double get thumbMaxScrollExtent => context.size!.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0);
|
||||
|
||||
double get thumbMinScrollExtent => 0.0;
|
||||
|
||||
|
@ -208,20 +206,20 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
onVerticalDragStart: (_) => _onVerticalDragStart(),
|
||||
onVerticalDragUpdate: (details) => _onVerticalDragUpdate(details.delta.dy),
|
||||
onVerticalDragEnd: (_) => _onVerticalDragEnd(),
|
||||
child: ValueListenableBuilder(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _thumbOffsetNotifier,
|
||||
builder: (context, thumbOffset, child) => Container(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
padding: EdgeInsets.only(top: thumbOffset) + widget.padding,
|
||||
padding: EdgeInsets.only(top: thumbOffset) + (widget.padding ?? EdgeInsets.zero),
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.scrollThumbHeight,
|
||||
labelText: (widget.labelTextBuilder != null && _isDragInProcess)
|
||||
? ValueListenableBuilder(
|
||||
? ValueListenableBuilder<double>(
|
||||
valueListenable: _viewOffsetNotifier,
|
||||
builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset),
|
||||
builder: (context, viewOffset, child) => widget.labelTextBuilder!.call(viewOffset + thumbOffset),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
@ -376,16 +374,16 @@ class SlideFadeTransition extends StatelessWidget {
|
|||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key key,
|
||||
@required this.animation,
|
||||
@required this.child,
|
||||
Key? key,
|
||||
required this.animation,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: Offset(0.3, 0.0),
|
||||
|
|
|
@ -27,7 +27,7 @@ class BottomGestureAreaProtector extends StatelessWidget {
|
|||
class GestureAreaProtectorStack extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const GestureAreaProtectorStack({@required this.child});
|
||||
const GestureAreaProtectorStack({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
|
|||
|
||||
class LabeledCheckbox extends StatefulWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
final String text;
|
||||
|
||||
const LabeledCheckbox({
|
||||
Key key,
|
||||
@required this.value,
|
||||
@required this.onChanged,
|
||||
@required this.text,
|
||||
Key? key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.text,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -18,7 +18,7 @@ class LabeledCheckbox extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _LabeledCheckboxState extends State<LabeledCheckbox> {
|
||||
TapGestureRecognizer _tapRecognizer;
|
||||
late TapGestureRecognizer _tapRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
|
@ -3,19 +3,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class LinkChip extends StatelessWidget {
|
||||
final Widget leading;
|
||||
final Widget? leading;
|
||||
final String text;
|
||||
final String url;
|
||||
final Color color;
|
||||
final TextStyle textStyle;
|
||||
final Color? color;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
static final borderRadius = BorderRadius.circular(8);
|
||||
|
||||
const LinkChip({
|
||||
Key key,
|
||||
Key? key,
|
||||
this.leading,
|
||||
@required this.text,
|
||||
@required this.url,
|
||||
required this.text,
|
||||
required this.url,
|
||||
this.color,
|
||||
this.textStyle,
|
||||
}) : super(key: key);
|
||||
|
@ -37,7 +37,7 @@ class LinkChip extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading,
|
||||
leading!,
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
|
|
|
@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
|
|||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final String text;
|
||||
final IconData icon;
|
||||
final bool checked;
|
||||
final IconData? icon;
|
||||
final bool? checked;
|
||||
|
||||
const MenuRow({
|
||||
Key key,
|
||||
this.text,
|
||||
Key? key,
|
||||
required this.text,
|
||||
this.icon,
|
||||
this.checked,
|
||||
}) : super(key: key);
|
||||
|
@ -16,12 +16,12 @@ class MenuRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = IconTheme.of(context).size * textScaleFactor;
|
||||
final iconSize = IconTheme.of(context).size! * textScaleFactor;
|
||||
return Row(
|
||||
children: [
|
||||
if (checked != null) ...[
|
||||
Opacity(
|
||||
opacity: checked ? 1 : 0,
|
||||
opacity: checked! ? 1 : 0,
|
||||
child: Icon(AIcons.checked, size: iconSize),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue