unsound null safety

This commit is contained in:
Thibault Deckers 2021-05-13 19:34:23 +09:00
parent 51bfb3cd04
commit 140ba900ce
252 changed files with 2821 additions and 2731 deletions

View file

@ -31,4 +31,4 @@ jobs:
run: flutter analyze run: flutter analyze
- name: Unit tests. - name: Unit tests.
run: flutter test run: flutter test --no-sound-null-safety

View file

@ -38,7 +38,7 @@ jobs:
run: flutter analyze run: flutter analyze
- name: Unit tests. - name: Unit tests.
run: flutter test run: flutter test --no-sound-null-safety
- name: Build signed artifacts. - name: Build signed artifacts.
# `KEY_JKS` should contain the result of: # `KEY_JKS` should contain the result of:

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/geo/topojson.dart'; import 'package:aves/geo/topojson.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -13,17 +14,17 @@ class CountryTopology {
CountryTopology._private(); 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 // returns the country containing given coordinates
Future<CountryCode> countryCode(LatLng position) async { Future<CountryCode?> countryCode(LatLng position) async {
return _countryOfNumeric(await numericCode(position)); return _countryOfNumeric(await numericCode(position));
} }
// returns the ISO 3166-1 numeric code of the country containing given coordinates // 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(); final topology = await getTopology();
if (topology == null) return null; if (topology == null) return null;
@ -34,21 +35,25 @@ class CountryTopology {
// returns a map of the given positions by country // returns a map of the given positions by country
Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async { Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async {
final numericMap = await numericCodeMap(positions); final numericMap = await numericCodeMap(positions);
numericMap.remove(null); if (numericMap == null) return {};
final codeMap = numericMap.map((key, value) {
final code = _countryOfNumeric(key); final codeMapEntries = numericMap.entries
return code == null ? null : MapEntry(code, value); .map((kv) {
}); final code = _countryOfNumeric(kv.key);
codeMap.remove(null); return MapEntry(code, kv.value);
return codeMap; })
.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 // 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(); final topology = await getTopology();
if (topology == null) return null; 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 { static Future<Map<int, Set<LatLng>>> _isoNumericCodeMap(_IsoNumericCodeMapData data) async {
@ -58,19 +63,21 @@ class CountryTopology {
final byCode = <int, Set<LatLng>>{}; final byCode = <int, Set<LatLng>>{};
for (final position in data.positions) { for (final position in data.positions) {
final code = _getNumeric(topology, countries, position); final code = _getNumeric(topology, countries, position);
if (code != null) {
byCode[code] = (byCode[code] ?? {})..add(position); byCode[code] = (byCode[code] ?? {})..add(position);
} }
}
return byCode; return byCode;
} catch (error, stack) { } catch (error, stack) {
// an unhandled error in a spawn isolate would make the app crash // an unhandled error in a spawn isolate would make the app crash
debugPrint('failed to get country codes with error=$error\n$stack'); 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 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; if (hit == null) return null;
// promote hit countries, assuming given positions are likely to come from the same countries // promote hit countries, assuming given positions are likely to come from the same countries
@ -79,12 +86,12 @@ class CountryTopology {
mruCountries.insert(0, hit); mruCountries.insert(0, hit);
} }
final idString = (hit.id as String); final idString = (hit.id as String?);
final code = idString == null ? null : int.tryParse(idString); final code = idString == null ? null : int.tryParse(idString);
return code; return code;
} }
static CountryCode _countryOfNumeric(int numeric) { static CountryCode? _countryOfNumeric(int? numeric) {
if (numeric == null) return null; if (numeric == null) return null;
try { try {
return CountryCode.ofNumeric(numeric); return CountryCode.ofNumeric(numeric);

View file

@ -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'] // returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
List<String> toDMS(LatLng latLng) { List<String> toDMS(LatLng latLng) {
if (latLng == null) return [];
final lat = latLng.latitude; final lat = latLng.latitude;
final lng = latLng.longitude; final lng = latLng.longitude;
return [ return [

View file

@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
// cf https://github.com/topojson/topojson-specification // cf https://github.com/topojson/topojson-specification
class TopoJson { class TopoJson {
Future<Topology> parse(String data) async { Future<Topology?> parse(String data) async {
return compute(_isoParse, data); return compute<String, Topology?>(_isoParse, data);
} }
static Topology _isoParse(String jsonData) { static Topology? _isoParse(String jsonData) {
try { try {
final data = json.decode(jsonData) as Map<String, dynamic>; final data = json.decode(jsonData) as Map<String, dynamic>;
return Topology.parse(data); return Topology.parse(data);
@ -23,7 +23,7 @@ class TopoJson {
enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection } enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection }
TopoJsonObjectType _parseTopoJsonObjectType(String data) { TopoJsonObjectType? _parseTopoJsonObjectType(String? data) {
switch (data) { switch (data) {
case 'Topology': case 'Topology':
return TopoJsonObjectType.topology; return TopoJsonObjectType.topology;
@ -46,7 +46,7 @@ TopoJsonObjectType _parseTopoJsonObjectType(String data) {
} }
class TopologyJsonObject { 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; 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 { class Topology extends TopologyJsonObject {
final Map<String, Geometry> objects; final Map<String, Geometry> objects;
final List<List<List<num>>> arcs; final List<List<List<num>>> arcs;
final Transform transform; final Transform? transform;
Topology.parse(Map<String, dynamic> data) 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(), 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, transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast<String, dynamic>()) : null,
super.parse(data); super.parse(data);
@ -69,8 +78,8 @@ class Topology extends TopologyJsonObject {
var x = 0, y = 0; var x = 0, y = 0;
arc = arc.map((quantized) { arc = arc.map((quantized) {
final absolute = List.of(quantized); final absolute = List.of(quantized);
absolute[0] = (x += quantized[0]) * transform.scale[0] + transform.translate[0]; absolute[0] = (x += quantized[0] as int) * transform!.scale[0] + transform!.translate[0];
absolute[1] = (y += quantized[1]) * transform.scale[1] + transform.translate[1]; absolute[1] = (y += quantized[1] as int) * transform!.scale[1] + transform!.translate[1];
return absolute; return absolute;
}).toList(); }).toList();
} }
@ -126,17 +135,18 @@ class Transform {
abstract class Geometry extends TopologyJsonObject { abstract class Geometry extends TopologyJsonObject {
final dynamic id; final dynamic id;
final Map<String, dynamic> properties; final Map<String, dynamic>? properties;
Geometry.parse(Map<String, dynamic> data) Geometry.parse(Map<String, dynamic> data)
: id = data.containsKey('id') ? data['id'] : null, : 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); super.parse(data);
static Geometry build(Map<String, dynamic> data) { static Geometry? build(Map<String, dynamic> data) {
final type = _parseTopoJsonObjectType(data['type'] as String); final type = _parseTopoJsonObjectType(data['type'] as String?);
switch (type) { switch (type) {
case TopoJsonObjectType.topology: case TopoJsonObjectType.topology:
case null:
return null; return null;
case TopoJsonObjectType.point: case TopoJsonObjectType.point:
return Point.parse(data); return Point.parse(data);
@ -153,7 +163,6 @@ abstract class Geometry extends TopologyJsonObject {
case TopoJsonObjectType.geometrycollection: case TopoJsonObjectType.geometrycollection:
return GeometryCollection.parse(data); return GeometryCollection.parse(data);
} }
return null;
} }
bool containsPoint(Topology topology, List<num> point) => false; 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(), : arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
super.parse(data); super.parse(data);
List<List<List<num>>> _rings; List<List<List<num>>>? _rings;
List<List<List<num>>> rings(Topology topology) { List<List<List<num>>> rings(Topology topology) {
_rings ??= topology._decodePolygonArcs(arcs); _rings ??= topology._decodePolygonArcs(arcs);
return _rings; return _rings!;
} }
@override @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(), : arcs = (data['arcs'] as List).cast<List>().map((polygon) => polygon.cast<List>().map((arc) => arc.cast<int>()).toList()).toList(),
super.parse(data); super.parse(data);
List<List<List<List<num>>>> _polygons; List<List<List<List<num>>>>? _polygons;
List<List<List<List<num>>>> polygons(Topology topology) { List<List<List<List<num>>>> polygons(Topology topology) {
_polygons ??= topology._decodeMultiPolygonArcs(arcs); _polygons ??= topology._decodeMultiPolygonArcs(arcs);
return _polygons; return _polygons!;
} }
@override @override
@ -235,7 +244,7 @@ class GeometryCollection extends Geometry {
final List<Geometry> geometries; final List<Geometry> geometries;
GeometryCollection.parse(Map<String, dynamic> data) 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); super.parse(data);
@override @override

View file

@ -6,11 +6,10 @@ import 'package:flutter/widgets.dart';
class AppIconImage extends ImageProvider<AppIconImageKey> { class AppIconImage extends ImageProvider<AppIconImageKey> {
const AppIconImage({ const AppIconImage({
@required this.packageName, required this.packageName,
@required this.size, required this.size,
this.scale = 1.0, this.scale = 1.0,
}) : assert(packageName != null), });
assert(scale != null);
final String packageName; final String packageName;
final double size; final double size;
@ -39,7 +38,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
try { try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
if (bytes == null) { if (bytes.isEmpty) {
throw StateError('$packageName app icon loading failed'); throw StateError('$packageName app icon loading failed');
} }
return await decode(bytes); return await decode(bytes);
@ -56,9 +55,9 @@ class AppIconImageKey {
final double scale; final double scale;
const AppIconImageKey({ const AppIconImageKey({
@required this.packageName, required this.packageName,
@required this.size, required this.size,
this.scale, this.scale = 1.0,
}); });
@override @override

View file

@ -9,7 +9,7 @@ import 'package:flutter/widgets.dart';
class RegionProvider extends ImageProvider<RegionProviderKey> { class RegionProvider extends ImageProvider<RegionProviderKey> {
final RegionProviderKey key; final RegionProviderKey key;
RegionProvider(this.key) : assert(key != null); RegionProvider(this.key);
@override @override
Future<RegionProviderKey> obtainKey(ImageConfiguration configuration) { Future<RegionProviderKey> obtainKey(ImageConfiguration configuration) {
@ -43,7 +43,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
pageId: pageId, pageId: pageId,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes.isEmpty) {
throw StateError('$uri ($mimeType) region loading failed'); throw StateError('$uri ($mimeType) region loading failed');
} }
return await decode(bytes); return await decode(bytes);
@ -66,30 +66,24 @@ class RegionProviderKey {
// do not store the entry as it is, because the key should be constant // do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time // but the entry attributes may change over time
final String uri, mimeType; final String uri, mimeType;
final int pageId, rotationDegrees, sampleSize; final int? pageId;
final int rotationDegrees, sampleSize;
final bool isFlipped; final bool isFlipped;
final Rectangle<int> region; final Rectangle<int> region;
final Size imageSize; final Size imageSize;
final double scale; final double scale;
const RegionProviderKey({ const RegionProviderKey({
@required this.uri, required this.uri,
@required this.mimeType, required this.mimeType,
@required this.pageId, required this.pageId,
@required this.rotationDegrees, required this.rotationDegrees,
@required this.isFlipped, required this.isFlipped,
@required this.sampleSize, required this.sampleSize,
@required this.region, required this.region,
@required this.imageSize, required this.imageSize,
this.scale = 1.0, 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -7,7 +7,7 @@ import 'package:flutter/widgets.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> { class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final ThumbnailProviderKey key; final ThumbnailProviderKey key;
ThumbnailProvider(this.key) : assert(key != null); ThumbnailProvider(this.key);
@override @override
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) { Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
@ -43,7 +43,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
extent: key.extent, extent: key.extent,
taskKey: key, taskKey: key,
); );
if (bytes == null) { if (bytes.isEmpty) {
throw StateError('$uri ($mimeType) loading failed'); throw StateError('$uri ($mimeType) loading failed');
} }
return await decode(bytes); return await decode(bytes);
@ -66,25 +66,21 @@ class ThumbnailProviderKey {
// do not store the entry as it is, because the key should be constant // do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time // but the entry attributes may change over time
final String uri, mimeType; final String uri, mimeType;
final int pageId, rotationDegrees; final int? pageId;
final int rotationDegrees;
final bool isFlipped; final bool isFlipped;
final int dateModifiedSecs; final int dateModifiedSecs;
final double extent; final double extent;
const ThumbnailProviderKey({ const ThumbnailProviderKey({
@required this.uri, required this.uri,
@required this.mimeType, required this.mimeType,
@required this.pageId, required this.pageId,
@required this.rotationDegrees, required this.rotationDegrees,
@required this.isFlipped, required this.isFlipped,
@required this.dateModifiedSecs, required this.dateModifiedSecs,
this.extent = 0, this.extent = 0,
}) : assert(uri != null), });
assert(mimeType != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(dateModifiedSecs != null),
assert(extent != null);
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -8,20 +8,19 @@ import 'package:pedantic/pedantic.dart';
class UriImage extends ImageProvider<UriImage> { class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType; final String uri, mimeType;
final int pageId, rotationDegrees, expectedContentLength; final int? pageId, rotationDegrees, expectedContentLength;
final bool isFlipped; final bool isFlipped;
final double scale; final double scale;
const UriImage({ const UriImage({
@required this.uri, required this.uri,
@required this.mimeType, required this.mimeType,
@required this.pageId, required this.pageId,
@required this.rotationDegrees, required this.rotationDegrees,
@required this.isFlipped, required this.isFlipped,
this.expectedContentLength, this.expectedContentLength,
this.scale = 1.0, this.scale = 1.0,
}) : assert(uri != null), });
assert(scale != null);
@override @override
Future<UriImage> obtainKey(ImageConfiguration configuration) { 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'); throw StateError('$uri ($mimeType) loading failed');
} }
return await decode(bytes); return await decode(bytes);

View file

@ -2,15 +2,13 @@ import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:pedantic/pedantic.dart';
class UriPicture extends PictureProvider<UriPicture> { class UriPicture extends PictureProvider<UriPicture> {
const UriPicture({ const UriPicture({
@required this.uri, required this.uri,
@required this.mimeType, required this.mimeType,
ColorFilter colorFilter, ColorFilter? colorFilter,
}) : assert(uri != null), }) : super(colorFilter);
super(colorFilter);
final String uri, mimeType; final String uri, mimeType;
@ -20,25 +18,30 @@ class UriPicture extends PictureProvider<UriPicture> {
} }
@override @override
PictureStreamCompleter load(UriPicture key, {PictureErrorListener onError}) { PictureStreamCompleter load(UriPicture key, {PictureErrorListener? onError}) {
return OneFramePictureStreamCompleter(_loadAsync(key, onError: onError), informationCollector: () sync* { return OneFramePictureStreamCompleter(_loadAsync(key, onError: onError), informationCollector: () sync* {
yield DiagnosticsProperty<String>('uri', uri); yield DiagnosticsProperty<String>('uri', uri);
}); });
} }
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async { Future<PictureInfo?> _loadAsync(UriPicture key, {PictureErrorListener? onError}) async {
assert(key == this); assert(key == this);
final data = await imageFileService.getSvg(uri, mimeType); final data = await imageFileService.getSvg(uri, mimeType);
if (data == null || data.isEmpty) { if (data.isEmpty) {
return null; return null;
} }
final decoder = SvgPicture.svgByteDecoder; final decoder = SvgPicture.svgByteDecoder;
if (onError != null) { if (onError != null) {
final future = decoder(data, colorFilter, key.toString()); return decoder(
unawaited(future.catchError(onError)); data,
return future; colorFilter,
key.toString(),
).catchError((error, stack) async {
onError(error, stack);
return Future<PictureInfo>.error(error, stack);
});
} }
return decoder(data, colorFilter, key.toString()); return decoder(data, colorFilter, key.toString());
} }

View file

@ -1,31 +1,9 @@
// @dart=2.9
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui';
import 'package:aves/app_mode.dart'; import 'package:aves/widgets/aves_app.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:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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() { void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging // HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -41,147 +19,3 @@ void main() {
runApp(AvesApp()); 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);
}
});
}
}
}

View file

@ -42,7 +42,6 @@ extension ExtraChipAction on ChipAction {
case ChipAction.setCover: case ChipAction.setCover:
return context.l10n.chipActionSetCover; return context.l10n.chipActionSetCover;
} }
return null;
} }
IconData getIcon() { IconData getIcon() {
@ -65,6 +64,5 @@ extension ExtraChipAction on ChipAction {
case ChipAction.setCover: case ChipAction.setCover:
return AIcons.setCover; return AIcons.setCover;
} }
return null;
} }
} }

View file

@ -91,10 +91,9 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.debug: case EntryAction.debug:
return 'Debug'; return 'Debug';
} }
return null;
} }
IconData getIcon() { IconData? getIcon() {
switch (this) { switch (this) {
// in app actions // in app actions
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
@ -129,6 +128,5 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.debug: case EntryAction.debug:
return AIcons.debug; return AIcons.debug;
} }
return null;
} }
} }

View file

@ -11,17 +11,17 @@ import 'package:version/version.dart';
abstract class AvesAvailability { abstract class AvesAvailability {
void onResume(); 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 canLocatePlaces;
Future<bool> get isNewVersionAvailable; Future<bool > get isNewVersionAvailable;
} }
class LiveAvesAvailability implements AvesAvailability { class LiveAvesAvailability implements AvesAvailability {
bool _isConnected, _hasPlayServices, _isNewVersionAvailable; bool? _isConnected, _hasPlayServices, _isNewVersionAvailable;
LiveAvesAvailability() { LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
@ -31,11 +31,11 @@ class LiveAvesAvailability implements AvesAvailability {
void onResume() => _isConnected = null; void onResume() => _isConnected = null;
@override @override
Future<bool> get isConnected async { Future<bool > get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected); if (_isConnected != null) return SynchronousFuture(_isConnected!);
final result = await (Connectivity().checkConnectivity()); final result = await (Connectivity().checkConnectivity());
_updateConnectivityFromResult(result); _updateConnectivityFromResult(result);
return _isConnected; return _isConnected!;
} }
void _updateConnectivityFromResult(ConnectivityResult result) { void _updateConnectivityFromResult(ConnectivityResult result) {
@ -47,12 +47,12 @@ class LiveAvesAvailability implements AvesAvailability {
} }
@override @override
Future<bool> get hasPlayServices async { Future<bool > get hasPlayServices async {
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices); if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices!);
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability(); final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
_hasPlayServices = result == GooglePlayServicesAvailability.success; _hasPlayServices = result == GooglePlayServicesAvailability.success;
debugPrint('Device has Play Services=$_hasPlayServices'); debugPrint('Device has Play Services=$_hasPlayServices');
return _hasPlayServices; return _hasPlayServices!;
} }
// local geocoding with `geocoder` requires Play Services // 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)); Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override @override
Future<bool> get isNewVersionAvailable async { Future<bool > get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
final now = DateTime.now(); final now = DateTime.now();
final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval); final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval);
if (now.isBefore(dueDate)) { if (now.isBefore(dueDate)) {
_isNewVersionAvailable = false; _isNewVersionAvailable = false;
return SynchronousFuture(_isNewVersionAvailable); return SynchronousFuture(_isNewVersionAvailable!);
} }
if (!(await isConnected)) return false; if (!(await isConnected)) return false;
Version version(String s) => Version.parse(s.replaceFirst('v', '')); Version version(String s) => Version.parse(s.replaceFirst('v', ''));
final currentTag = (await PackageInfo.fromPlatform()).version; 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); _isNewVersionAvailable = version(latestTag) > version(currentTag);
if (_isNewVersionAvailable) { if (_isNewVersionAvailable!) {
debugPrint('Aves $latestTag is available on github'); debugPrint('Aves $latestTag is available on github');
} else { } else {
debugPrint('Aves $currentTag is the latest version'); debugPrint('Aves $currentTag is the latest version');
settings.lastVersionCheckDate = now; settings.lastVersionCheckDate = now;
} }
return _isNewVersionAvailable; return _isNewVersionAvailable!;
} }
} }

View file

@ -2,6 +2,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -18,19 +19,19 @@ class Covers with ChangeNotifier {
int get count => _rows.length; 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 // erase contextual properties from filters before saving them
if (filter is AlbumFilter) { 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); _rows.removeWhere((row) => row.filter == filter);
if (contentId == null) { if (contentId == null) {
await metadataDb.removeCovers({row}); await metadataDb.removeCovers({filter});
} else { } else {
final row = CoverRow(filter: filter, contentId: contentId);
_rows.add(row); _rows.add(row);
await metadataDb.addCovers({row}); await metadataDb.addCovers({row});
} }
@ -46,11 +47,11 @@ class Covers with ChangeNotifier {
final filter = oldRow.filter; final filter = oldRow.filter;
_rows.remove(oldRow); _rows.remove(oldRow);
if (filter.test(entry)) { 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); await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
_rows.add(newRow); _rows.add(newRow);
} else { } 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 contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.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); _rows.removeAll(removedRows);
notifyListeners(); notifyListeners();
@ -81,13 +82,15 @@ class CoverRow {
final int contentId; final int contentId;
const CoverRow({ const CoverRow({
@required this.filter, required this.filter,
@required this.contentId, 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( return CoverRow(
filter: CollectionFilter.fromJson(map['filter']), filter: filter,
contentId: map['contentId'], contentId: map['contentId'],
); );
} }

View file

@ -22,22 +22,22 @@ import '../ref/mime_types.dart';
class AvesEntry { class AvesEntry {
String uri; String uri;
String _path, _directory, _filename, _extension; String? _path, _directory, _filename, _extension;
int pageId, contentId; int? pageId, contentId;
final String sourceMimeType; final String sourceMimeType;
int width; int width;
int height; int height;
int sourceRotationDegrees; int sourceRotationDegrees;
final int sizeBytes; final int? sizeBytes;
String _sourceTitle; String? _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode // `dateModifiedSecs` can be missing in viewer mode
int _dateModifiedSecs; int? _dateModifiedSecs;
final int sourceDateTakenMillis; final int? sourceDateTakenMillis;
final int durationMillis; final int? durationMillis;
int _catalogDateMillis; int? _catalogDateMillis;
CatalogMetadata _catalogMetadata; CatalogMetadata? _catalogMetadata;
AddressDetails _addressDetails; AddressDetails? _addressDetails;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
@ -51,21 +51,20 @@ class AvesEntry {
]; ];
AvesEntry({ AvesEntry({
this.uri, required this.uri,
String path, required String? path,
this.contentId, required this.contentId,
this.pageId, required this.pageId,
this.sourceMimeType, required this.sourceMimeType,
@required this.width, required this.width,
@required this.height, required this.height,
this.sourceRotationDegrees, required this.sourceRotationDegrees,
this.sizeBytes, required this.sizeBytes,
String sourceTitle, required String? sourceTitle,
int dateModifiedSecs, required int? dateModifiedSecs,
this.sourceDateTakenMillis, required this.sourceDateTakenMillis,
this.durationMillis, required this.durationMillis,
}) : assert(width != null), }) {
assert(height != null) {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle; this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs; this.dateModifiedSecs = dateModifiedSecs;
@ -76,16 +75,17 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({ AvesEntry copyWith({
String uri, String? uri,
String path, String? path,
int contentId, int? contentId,
int dateModifiedSecs, int? dateModifiedSecs,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry( final copied = AvesEntry(
uri: uri ?? this.uri, uri: uri ?? this.uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: copyContentId,
pageId: null,
sourceMimeType: sourceMimeType, sourceMimeType: sourceMimeType,
width: width, width: width,
height: height, height: height,
@ -106,17 +106,18 @@ class AvesEntry {
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(
uri: map['uri'] as String, uri: map['uri'] as String,
path: map['path'] as String, path: map['path'] as String?,
contentId: map['contentId'] as int, pageId: null,
contentId: map['contentId'] as int?,
sourceMimeType: map['sourceMimeType'] as String, sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int ?? 0, width: map['width'] as int? ?? 0,
height: map['height'] as int ?? 0, height: map['height'] as int? ?? 0,
sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0, sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
sizeBytes: map['sizeBytes'] as int, sizeBytes: map['sizeBytes'] as int?,
sourceTitle: map['title'] as String, sourceTitle: map['title'] as String?,
dateModifiedSecs: map['dateModifiedSecs'] as int, dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int, sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int, durationMillis: map['durationMillis'] as int?,
); );
} }
@ -150,27 +151,27 @@ class AvesEntry {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
set path(String path) { set path(String? path) {
_path = path; _path = path;
_directory = null; _directory = null;
_filename = null; _filename = null;
_extension = null; _extension = null;
} }
String get path => _path; String? get path => _path;
String get directory { String? get directory {
_directory ??= path != null ? pContext.dirname(path) : null; _directory ??= path != null ? pContext.dirname(path!) : null;
return _directory; return _directory;
} }
String get filenameWithoutExtension { String? get filenameWithoutExtension {
_filename ??= path != null ? pContext.basenameWithoutExtension(path) : null; _filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
return _filename; return _filename;
} }
String get extension { String? get extension {
_extension ??= path != null ? pContext.extension(path) : null; _extension ??= path != null ? pContext.extension(path!) : null;
return _extension; return _extension;
} }
@ -258,16 +259,16 @@ class AvesEntry {
static const ratioSeparator = '\u2236'; static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 '; static const resolutionSeparator = ' \u00D7 ';
bool get isSized => (width ?? 0) > 0 && (height ?? 0) > 0; bool get isSized => width > 0 && height > 0;
String get resolutionText { String get resolutionText {
final ws = width ?? '?'; final ws = width;
final hs = height ?? '?'; final hs = height;
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs'; return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
} }
String get aspectRatioText { String get aspectRatioText {
if (width != null && height != null && width > 0 && height > 0) { if (width > 0 && height > 0) {
final gcd = width.gcd(height); final gcd = width.gcd(height);
final w = width ~/ gcd; final w = width ~/ gcd;
final h = height ~/ gcd; final h = height ~/ gcd;
@ -288,24 +289,24 @@ class AvesEntry {
return isRotated ? Size(h, w) : Size(w, h); 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 (_bestDate == null) {
if ((_catalogDateMillis ?? 0) > 0) { if ((_catalogDateMillis ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis); _bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis!);
} else if ((sourceDateTakenMillis ?? 0) > 0) { } else if ((sourceDateTakenMillis ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis); _bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis!);
} else if ((dateModifiedSecs ?? 0) > 0) { } else if ((dateModifiedSecs ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); _bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs! * 1000);
} }
} }
return _bestDate; return _bestDate;
} }
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0; int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) { set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees; sourceRotationDegrees = rotationDegrees;
@ -316,78 +317,78 @@ class AvesEntry {
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; 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; _sourceTitle = sourceTitle;
_bestTitle = null; _bestTitle = null;
} }
int get dateModifiedSecs => _dateModifiedSecs; int? get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) { set dateModifiedSecs(int? dateModifiedSecs) {
_dateModifiedSecs = dateModifiedSecs; _dateModifiedSecs = dateModifiedSecs;
_bestDate = null; _bestDate = null;
} }
DateTime get monthTaken { DateTime? get monthTaken {
final d = bestDate; final d = bestDate;
return d == null ? null : DateTime(d.year, d.month); return d == null ? null : DateTime(d.year, d.month);
} }
DateTime get dayTaken { DateTime? get dayTaken {
final d = bestDate; final d = bestDate;
return d == null ? null : DateTime(d.year, d.month, d.day); return d == null ? null : DateTime(d.year, d.month, d.day);
} }
String _durationText; String? _durationText;
String get durationText { String get durationText {
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0)); _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
return _durationText; return _durationText!;
} }
// returns whether this entry has GPS coordinates // returns whether this entry has GPS coordinates
// (0, 0) coordinates are considered invalid, as it is likely a default value // (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; bool get hasAddress => _addressDetails != null;
// has a place, or at least the full country name // has a place, or at least the full country name
// derived from Google reverse geocoding addresses // 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; if (!hasGps) return null;
final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6); final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6); final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
return 'geo:$latitude,$longitude?q=$latitude,$longitude'; return 'geo:$latitude,$longitude?q=$latitude,$longitude';
} }
List<String> _xmpSubjects; List<String>? _xmpSubjects;
List<String> get xmpSubjects { List<String> get xmpSubjects {
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
return _xmpSubjects; return _xmpSubjects!;
} }
String _bestTitle; String? _bestTitle;
String get bestTitle { String? get bestTitle {
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle; _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle;
return _bestTitle; return _bestTitle;
} }
CatalogMetadata get catalogMetadata => _catalogMetadata; CatalogMetadata? get catalogMetadata => _catalogMetadata;
set catalogDateMillis(int dateMillis) { set catalogDateMillis(int? dateMillis) {
_catalogDateMillis = dateMillis; _catalogDateMillis = dateMillis;
_bestDate = null; _bestDate = null;
} }
set catalogMetadata(CatalogMetadata newMetadata) { set catalogMetadata(CatalogMetadata? newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees; final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped; 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; _addressDetails = newAddress;
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notifyListeners();
} }
Future<void> locate({@required bool background}) async { Future<void> locate({required bool background}) async {
if (!hasGps) return; if (!hasGps) return;
await _locateCountry(); await _locateCountry();
if (await availability.canLocatePlaces) { if (await availability.canLocatePlaces) {
@ -442,11 +443,11 @@ class AvesEntry {
// quick reverse geocoding to find the country, using an offline asset // quick reverse geocoding to find the country, using an offline asset
Future<void> _locateCountry() async { Future<void> _locateCountry() async {
if (!hasGps || hasAddress) return; if (!hasGps || hasAddress) return;
final countryCode = await countryTopology.countryCode(latLng); final countryCode = await countryTopology.countryCode(latLng!);
setCountry(countryCode); setCountry(countryCode);
} }
void setCountry(CountryCode countryCode) { void setCountry(CountryCode? countryCode) {
if (hasFineAddress || countryCode == null) return; if (hasFineAddress || countryCode == null) return;
addressDetails = AddressDetails( addressDetails = AddressDetails(
contentId: contentId, contentId: contentId,
@ -455,25 +456,25 @@ class AvesEntry {
); );
} }
String _geocoderLocale; String? _geocoderLocale;
String get geocoderLocale { String get geocoderLocale {
_geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance.window.locale).toString(); _geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString();
return _geocoderLocale; return _geocoderLocale!;
} }
// full reverse geocoding, requiring Play Services and some connectivity // 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; if (!hasGps || hasFineAddress) return;
try { try {
Future<List<Address>> call() => GeocodingService.getAddress(latLng, geocoderLocale); Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
final addresses = await (background final addresses = await (background
? servicePolicy.call( ? servicePolicy.call(
call, call,
priority: ServiceCallPriority.getLocation, priority: ServiceCallPriority.getLocation,
) )
: call()); : call());
if (addresses != null && addresses.isNotEmpty) { if (addresses.isNotEmpty) {
final address = addresses.first; final address = addresses.first;
final cc = address.countryCode; final cc = address.countryCode;
final cn = address.countryName; final cn = address.countryName;
@ -493,12 +494,12 @@ class AvesEntry {
} }
} }
Future<String> findAddressLine() async { Future<String?> findAddressLine() async {
if (!hasGps) return null; if (!hasGps) return null;
try { try {
final addresses = await GeocodingService.getAddress(latLng, geocoderLocale); final addresses = await GeocodingService.getAddress(latLng!, geocoderLocale);
if (addresses != null && addresses.isNotEmpty) { if (addresses.isNotEmpty) {
final address = addresses.first; final address = addresses.first;
return address.addressLine; return address.addressLine;
} }
@ -549,12 +550,12 @@ class AvesEntry {
if (isFlipped is bool) this.isFlipped = isFlipped; if (isFlipped is bool) this.isFlipped = isFlipped;
await metadataDb.saveEntries({this}); await metadataDb.saveEntries({this});
await metadataDb.saveMetadata({catalogMetadata}); if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<bool> rotate({@required bool clockwise}) async { Future<bool> rotate({required bool clockwise}) async {
final newFields = await imageFileService.rotate(this, clockwise: clockwise); final newFields = await imageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
@ -579,7 +580,7 @@ class AvesEntry {
} }
Future<bool> delete() { Future<bool> delete() {
Completer completer = Completer<bool>(); final completer = Completer<bool>();
imageFileService.delete([this]).listen( imageFileService.delete([this]).listen(
(event) => completer.complete(event.success), (event) => completer.complete(event.success),
onError: completer.completeError, onError: completer.completeError,
@ -593,7 +594,7 @@ class AvesEntry {
} }
// when the entry image itself changed (e.g. after rotation) // 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) { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners(); imageChangeNotifier.notifyListeners();
@ -626,15 +627,15 @@ class AvesEntry {
// 1) title ascending // 1) title ascending
// 2) extension ascending // 2) extension ascending
static int compareByName(AvesEntry a, AvesEntry b) { static int compareByName(AvesEntry a, AvesEntry b) {
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle); final c = compareAsciiUpperCase(a.bestTitle ?? '', b.bestTitle ?? '');
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension); return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
} }
// compare by: // compare by:
// 1) size descending // 1) size descending
// 2) name ascending // 2) name ascending
static int compareBySize(AvesEntry a, AvesEntry b) { 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); return c != 0 ? c : compareByName(a, b);
} }

View file

@ -8,12 +8,12 @@ class EntryCache {
static Future<void> evict( static Future<void> evict(
String uri, String uri,
String mimeType, String mimeType,
int dateModifiedSecs, int? dateModifiedSecs,
int oldRotationDegrees, int oldRotationDegrees,
bool oldIsFlipped, bool oldIsFlipped,
) async { ) async {
// TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them // 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 // evict fullscreen image
await UriImage( await UriImage(
@ -29,7 +29,7 @@ class EntryCache {
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
)).evict(); )).evict();
@ -42,7 +42,7 @@ class EntryCache {
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees, rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped, isFlipped: oldIsFlipped,
extent: extent, extent: extent,

View file

@ -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/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry { 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)); return RegionProvider(_getRegionProviderKey(sampleSize, region));
} }
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int> region) { RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int>? region) {
return RegionProviderKey( return RegionProviderKey(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
@ -56,7 +55,7 @@ extension ExtraAvesEntry on AvesEntry {
expectedContentLength: sizeBytes, expectedContentLength: sizeBytes,
); );
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive; bool _isReady(Object providerKey) => imageCache!.statusForKey(providerKey).keepAlive;
ImageProvider getBestThumbnail(double extent) { ImageProvider getBestThumbnail(double extent) {
final sizedThumbnailKey = _getThumbnailProviderKey(extent); final sizedThumbnailKey = _getThumbnailProviderKey(extent);

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.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); 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 { Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow); final newRows = entries.map(_entryToRow);
@ -40,7 +41,7 @@ class Favourites with ChangeNotifier {
} }
Future<void> moveEntry(int oldContentId, AvesEntry entry) async { 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; if (oldRow == null) return;
final newRow = _entryToRow(entry); final newRow = _entryToRow(entry);
@ -66,13 +67,13 @@ class FavouriteRow {
final String path; final String path;
const FavouriteRow({ const FavouriteRow({
this.contentId, required this.contentId,
this.path, required this.path,
}); });
factory FavouriteRow.fromMap(Map map) { factory FavouriteRow.fromMap(Map map) {
return FavouriteRow( return FavouriteRow(
contentId: map['contentId'], contentId: map['contentId'] ?? 0,
path: map['path'] ?? '', path: map['path'] ?? '',
); );
} }

View file

@ -14,7 +14,7 @@ class AlbumFilter extends CollectionFilter {
static final Map<String, Color> _appColors = {}; static final Map<String, Color> _appColors = {};
final String album; final String album;
final String displayName; final String? displayName;
const AlbumFilter(this.album, this.displayName); const AlbumFilter(this.album, this.displayName);
@ -41,10 +41,10 @@ class AlbumFilter extends CollectionFilter {
String getTooltip(BuildContext context) => album; String getTooltip(BuildContext context) => album;
@override @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( return IconUtils.getAlbumIcon(
context: context, context: context,
album: album, albumPath: album,
size: size, size: size,
embossed: embossed, embossed: embossed,
) ?? ) ??
@ -52,26 +52,25 @@ class AlbumFilter extends CollectionFilter {
} }
@override @override
Future<Color> color(BuildContext context) { Future<Color > color(BuildContext context) {
// do not use async/await and rely on `SynchronousFuture` // do not use async/await and rely on `SynchronousFuture`
// to prevent rebuilding of the `FutureBuilder` listening on this future // to prevent rebuilding of the `FutureBuilder` listening on this future
if (androidFileUtils.getAlbumType(album) == AlbumType.app) { 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( return PaletteGenerator.fromImageProvider(
AppIconImage( AppIconImage(packageName: packageName, size: 24),
packageName: androidFileUtils.getAlbumAppPackageName(album), ).then((palette) async {
size: 24, final color = palette.dominantColor?.color ?? (await super.color(context));
),
).then((palette) {
final color = palette.dominantColor?.color ?? super.color(context);
_appColors[album] = color; _appColors[album] = color;
return color; return color;
}); });
} else {
return super.color(context);
} }
} }
return super.color(context);
}
@override @override
String get category => type; String get category => type;

View file

@ -24,7 +24,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
TagFilter.type, TagFilter.type,
]; ];
static CollectionFilter fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
final jsonMap = jsonDecode(jsonString); final jsonMap = jsonDecode(jsonString);
final type = jsonMap['type']; final type = jsonMap['type'];
switch (type) { switch (type) {
@ -63,7 +63,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String getTooltip(BuildContext context) => getLabel(context); 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))); Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));
@ -84,7 +84,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
class FilterGridItem<T extends CollectionFilter> { class FilterGridItem<T extends CollectionFilter> {
final T filter; final T filter;
final AvesEntry entry; final AvesEntry? entry;
const FilterGridItem(this.filter, this.entry); const FilterGridItem(this.filter, this.entry);

View file

@ -1,6 +1,7 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -10,8 +11,8 @@ class LocationFilter extends CollectionFilter {
final LocationLevel level; final LocationLevel level;
String _location; String _location;
String _countryCode; String? _countryCode;
EntryFilter _test; late EntryFilter _test;
LocationFilter(this.level, this._location) { LocationFilter(this.level, this._location) {
final split = _location.split(locationSeparator); final split = _location.split(locationSeparator);
@ -29,7 +30,7 @@ class LocationFilter extends CollectionFilter {
LocationFilter.fromMap(Map<String, dynamic> json) LocationFilter.fromMap(Map<String, dynamic> json)
: this( : this(
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null), LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
json['location'], json['location'],
); );
@ -42,7 +43,7 @@ class LocationFilter extends CollectionFilter {
String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
String get countryCode => _countryCode; String? get countryCode => _countryCode;
@override @override
EntryFilter get test => _test; EntryFilter get test => _test;
@ -90,8 +91,9 @@ class LocationFilter extends CollectionFilter {
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041; static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;
static String countryCodeToFlag(String code) { static String? countryCodeToFlag(String? code) {
return code?.length == 2 ? String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff)) : null; if (code == null || code.length != 2) return null;
return String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff));
} }
} }

View file

@ -10,9 +10,9 @@ class MimeFilter extends CollectionFilter {
static const type = 'mime'; static const type = 'mime';
final String mime; final String mime;
EntryFilter _test; late EntryFilter _test;
String _label; late String _label;
IconData _icon; IconData? /*late*/ _icon;
static final image = MimeFilter(MimeTypes.anyImage); static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo); static final video = MimeFilter(MimeTypes.anyVideo);

View file

@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
final String query; final String query;
final bool colorful; final bool colorful;
EntryFilter _test; late EntryFilter _test;
QueryFilter(this.query, {this.colorful = true}) { QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter {
// allow untrimmed queries wrapped with `"..."` // allow untrimmed queries wrapped with `"..."`
final matches = exactRegex.allMatches(upQuery); final matches = exactRegex.allMatches(upQuery);
if (matches.length == 1) { if (matches.length == 1) {
upQuery = matches.first.group(1); upQuery = matches.first.group(1)!;
} }
_test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); _test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);

View file

@ -8,7 +8,7 @@ class TagFilter extends CollectionFilter {
static const type = 'tag'; static const type = 'tag';
final String tag; final String tag;
EntryFilter _test; late EntryFilter _test;
TagFilter(this.tag) { TagFilter(this.tag) {
if (tag.isEmpty) { if (tag.isEmpty) {
@ -42,7 +42,7 @@ class TagFilter extends CollectionFilter {
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
@override @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 @override
String get category => type; String get category => type;

View file

@ -14,8 +14,8 @@ class TypeFilter extends CollectionFilter {
static const _sphericalVideo = 'spherical_video'; // subset of videos static const _sphericalVideo = 'spherical_video'; // subset of videos
final String itemType; final String itemType;
EntryFilter _test; late EntryFilter _test;
IconData _icon; IconData? /*late*/ _icon;
static final animated = TypeFilter._private(_animated); static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);

View file

@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class HighlightInfo extends ChangeNotifier { class HighlightInfo extends ChangeNotifier {
Object _item; Object? _item;
void set(Object item) { void set(Object item) {
if (_item == item) return; if (_item == item) return;
@ -9,7 +9,7 @@ class HighlightInfo extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Object clear() { Object? clear() {
if (_item == null) return null; if (_item == null) return null;
final item = _item; final item = _item;
_item = null; _item = null;

View file

@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateMetadata { class DateMetadata {
final int contentId, dateMillis; final int? contentId, dateMillis;
DateMetadata({ DateMetadata({
this.contentId, this.contentId,
@ -28,13 +28,13 @@ class DateMetadata {
} }
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis; final int? contentId, dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage; final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped; bool isFlipped;
int rotationDegrees; int? rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription; final String? mimeType, xmpSubjects, xmpTitleDescription;
double latitude, longitude; double? latitude, longitude;
Address address; Address? address;
static const double _precisionErrorTolerance = 1e-9; static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0; static const _isAnimatedMask = 1 << 0;
@ -55,23 +55,28 @@ class CatalogMetadata {
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
double latitude, double? latitude,
double longitude, 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}), // 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. // but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case.
if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) { if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) {
this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude; // funny case: some files have latitude and longitude reverse
this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude; // (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({ CatalogMetadata copyWith({
int contentId, int? contentId,
String mimeType, String? mimeType,
bool isMultiPage, bool? isMultiPage,
int rotationDegrees, int? rotationDegrees,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
@ -127,16 +132,16 @@ class CatalogMetadata {
} }
class OverlayMetadata { class OverlayMetadata {
final String aperture, exposureTime, focalLength, iso; final String? aperture, exposureTime, focalLength, iso;
static final apertureFormat = NumberFormat('0.0', 'en_US'); static final apertureFormat = NumberFormat('0.0', 'en_US');
static final focalLengthFormat = NumberFormat('0.#', 'en_US'); static final focalLengthFormat = NumberFormat('0.#', 'en_US');
OverlayMetadata({ OverlayMetadata({
double aperture, double? aperture,
String exposureTime, String? exposureTime,
double focalLength, double? focalLength,
int iso, int? iso,
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
exposureTime = exposureTime, exposureTime = exposureTime,
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null, focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
@ -144,10 +149,10 @@ class OverlayMetadata {
factory OverlayMetadata.fromMap(Map map) { factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata( return OverlayMetadata(
aperture: map['aperture'] as double, aperture: map['aperture'] as double?,
exposureTime: map['exposureTime'] as String, exposureTime: map['exposureTime'] as String?,
focalLength: map['focalLength'] as double, focalLength: map['focalLength'] as double?,
iso: map['iso'] as int, iso: map['iso'] as int?,
); );
} }
@ -159,10 +164,10 @@ class OverlayMetadata {
@immutable @immutable
class AddressDetails { class AddressDetails {
final int contentId; final int? contentId;
final String countryCode, countryName, adminArea, locality; 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({ const AddressDetails({
this.contentId, this.contentId,
@ -173,7 +178,7 @@ class AddressDetails {
}); });
AddressDetails copyWith({ AddressDetails copyWith({
int contentId, int? contentId,
}) { }) {
return AddressDetails( return AddressDetails(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
@ -186,11 +191,11 @@ class AddressDetails {
factory AddressDetails.fromMap(Map map) { factory AddressDetails.fromMap(Map map) {
return AddressDetails( return AddressDetails(
contentId: map['contentId'], contentId: map['contentId'] as int?,
countryCode: map['countryCode'] ?? '', countryCode: map['countryCode'] as String?,
countryName: map['countryName'] ?? '', countryName: map['countryName'] as String?,
adminArea: map['adminArea'] ?? '', adminArea: map['adminArea'] as String?,
locality: map['locality'] ?? '', locality: map['locality'] as String?,
); );
} }

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.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.dart';
import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
@ -16,7 +17,7 @@ abstract class MetadataDb {
Future<void> reset(); Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}); Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
// entries // entries
@ -40,9 +41,9 @@ abstract class MetadataDb {
Future<List<CatalogMetadata>> loadMetadataEntries(); 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 // address
@ -50,9 +51,9 @@ abstract class MetadataDb {
Future<List<AddressDetails>> loadAddresses(); 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 // favourites
@ -76,11 +77,11 @@ abstract class MetadataDb {
Future<void> updateCoverEntryId(int oldId, CoverRow row); Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Iterable<CoverRow> rows); Future<void> removeCovers(Set<CollectionFilter> filters);
} }
class SqfliteMetadataDb implements MetadataDb { class SqfliteMetadataDb implements MetadataDb {
Future<Database> _database; late Future<Database> _database;
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db'); Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
@ -150,8 +151,8 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<int> dbFileSize() async { Future<int> dbFileSize() async {
final file = File((await path)); final file = File(await path);
return await file.exists() ? file.length() : 0; return await file.exists() ? await file.length() : 0;
} }
@override @override
@ -163,8 +164,8 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) async { Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
if (contentIds == null || contentIds.isEmpty) return; if (contentIds.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -207,7 +208,7 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> saveEntries(Iterable<AvesEntry> entries) async { Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries == null || entries.isEmpty) return; if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -226,7 +227,6 @@ class SqfliteMetadataDb implements MetadataDb {
} }
void _batchInsertEntry(Batch batch, AvesEntry entry) { void _batchInsertEntry(Batch batch, AvesEntry entry) {
if (entry == null) return;
batch.insert( batch.insert(
entryTable, entryTable,
entry.toMap(), entry.toMap(),
@ -273,13 +273,13 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async { Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return; if (metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
final db = await _database; final db = await _database;
final batch = db.batch(); 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); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (error, stack) { } catch (error, stack) {
@ -288,7 +288,7 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async { Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]); batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]);
@ -297,7 +297,7 @@ class SqfliteMetadataDb implements MetadataDb {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
void _batchInsertMetadata(Batch batch, CatalogMetadata metadata) { void _batchInsertMetadata(Batch batch, CatalogMetadata? metadata) {
if (metadata == null) return; if (metadata == null) return;
if (metadata.dateMillis != 0) { if (metadata.dateMillis != 0) {
batch.insert( batch.insert(
@ -333,18 +333,18 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async { Future<void> saveAddresses(Set<AddressDetails> addresses) async {
if (addresses == null || addresses.isEmpty) return; if (addresses.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final batch = db.batch(); 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); await batch.commit(noResult: true);
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
} }
@override @override
Future<void> updateAddressId(int oldId, AddressDetails address) async { Future<void> updateAddressId(int oldId, AddressDetails? address) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]); batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
@ -352,7 +352,7 @@ class SqfliteMetadataDb implements MetadataDb {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
void _batchInsertAddress(Batch batch, AddressDetails address) { void _batchInsertAddress(Batch batch, AddressDetails? address) {
if (address == null) return; if (address == null) return;
batch.insert( batch.insert(
addressTable, addressTable,
@ -380,10 +380,10 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> addFavourites(Iterable<FavouriteRow> rows) async { Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
if (rows == null || rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final db = await _database;
final batch = db.batch(); 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); await batch.commit(noResult: true);
} }
@ -397,7 +397,6 @@ class SqfliteMetadataDb implements MetadataDb {
} }
void _batchInsertFavourite(Batch batch, FavouriteRow row) { void _batchInsertFavourite(Batch batch, FavouriteRow row) {
if (row == null) return;
batch.insert( batch.insert(
favouriteTable, favouriteTable,
row.toMap(), row.toMap(),
@ -407,8 +406,8 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async { Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
if (rows == null || rows.isEmpty) return; if (rows.isEmpty) return;
final ids = rows.where((row) => row != null).map((row) => row.contentId); final ids = rows.map((row) => row.contentId);
if (ids.isEmpty) return; if (ids.isEmpty) return;
final db = await _database; final db = await _database;
@ -431,16 +430,16 @@ class SqfliteMetadataDb implements MetadataDb {
Future<Set<CoverRow>> loadCovers() async { Future<Set<CoverRow>> loadCovers() async {
final db = await _database; final db = await _database;
final maps = await db.query(coverTable); 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; return rows;
} }
@override @override
Future<void> addCovers(Iterable<CoverRow> rows) async { Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows == null || rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final db = await _database;
final batch = db.batch(); 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); await batch.commit(noResult: true);
} }
@ -454,7 +453,6 @@ class SqfliteMetadataDb implements MetadataDb {
} }
void _batchInsertCover(Batch batch, CoverRow row) { void _batchInsertCover(Batch batch, CoverRow row) {
if (row == null) return;
batch.insert( batch.insert(
coverTable, coverTable,
row.toMap(), row.toMap(),
@ -463,9 +461,7 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> removeCovers(Iterable<CoverRow> rows) async { Future<void> removeCovers(Set<CollectionFilter> filters) async {
if (rows == null || rows.isEmpty) return;
final filters = rows.where((row) => row != null).map((row) => row.filter);
if (filters.isEmpty) return; if (filters.isEmpty) return;
final db = await _database; final db = await _database;

View file

@ -1,6 +1,7 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class MultiPageInfo { class MultiPageInfo {
@ -11,8 +12,8 @@ class MultiPageInfo {
int get pageCount => _pages.length; int get pageCount => _pages.length;
MultiPageInfo({ MultiPageInfo({
@required this.mainEntry, required this.mainEntry,
List<SinglePageInfo> pages, required List<SinglePageInfo> pages,
}) : _pages = pages { }) : _pages = pages {
if (_pages.isNotEmpty) { if (_pages.isNotEmpty) {
_pages.sort(); _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) { if (pageInfo != null) {
return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo)); return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo));
} else { } else {
@ -52,20 +53,20 @@ class MultiPageInfo {
List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList(); List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList();
Future<void> extractMotionPhotoVideo() async { 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) { if (videoPage != null && videoPage.uri == null) {
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry); final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
if (fields != null) { if (fields.containsKey('uri')) {
final pageIndex = _pages.indexOf(videoPage); final pageIndex = _pages.indexOf(videoPage);
_pages.removeAt(pageIndex); _pages.removeAt(pageIndex);
_pages.insert( _pages.insert(
pageIndex, pageIndex,
videoPage.copyWith( videoPage.copyWith(
uri: fields['uri'] as String, uri: fields['uri'] as String?,
// the initial fake page may contain inaccurate values for the following fields // the initial fake page may contain inaccurate values for the following fields
// so we override them with values from the extracted standalone video // so we override them with values from the extracted standalone video
rotationDegrees: fields['sourceRotationDegrees'] as int, rotationDegrees: fields['sourceRotationDegrees'] as int?,
durationMillis: fields['durationMillis'] as int, durationMillis: fields['durationMillis'] as int?,
)); ));
_pageEntries.remove(videoPage); _pageEntries.remove(videoPage);
} }
@ -83,9 +84,9 @@ class MultiPageInfo {
path: mainEntry.path, path: mainEntry.path,
contentId: mainEntry.contentId, contentId: mainEntry.contentId,
pageId: pageId, pageId: pageId,
sourceMimeType: pageInfo.mimeType ?? mainEntry.sourceMimeType, sourceMimeType: pageInfo.mimeType,
width: pageInfo.width ?? mainEntry.width, width: pageInfo.width,
height: pageInfo.height ?? mainEntry.height, height: pageInfo.height,
sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees, sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees,
sizeBytes: mainEntry.sizeBytes, sizeBytes: mainEntry.sizeBytes,
sourceTitle: mainEntry.sourceTitle, sourceTitle: mainEntry.sourceTitle,
@ -108,26 +109,28 @@ class MultiPageInfo {
class SinglePageInfo implements Comparable<SinglePageInfo> { class SinglePageInfo implements Comparable<SinglePageInfo> {
final int index, pageId; final int index, pageId;
final bool isDefault; final bool isDefault;
final String uri, mimeType; final String? uri;
final int width, height, rotationDegrees, durationMillis; final String mimeType;
final int width, height;
final int? rotationDegrees, durationMillis;
const SinglePageInfo({ const SinglePageInfo({
this.index, required this.index,
this.pageId, required this.pageId,
this.isDefault, required this.isDefault,
this.uri, this.uri,
this.mimeType, required this.mimeType,
this.width, required this.width,
this.height, required this.height,
this.rotationDegrees, this.rotationDegrees,
this.durationMillis, this.durationMillis,
}); });
SinglePageInfo copyWith({ SinglePageInfo copyWith({
bool isDefault, bool? isDefault,
String uri, String? uri,
int rotationDegrees, int? rotationDegrees,
int durationMillis, int? durationMillis,
}) { }) {
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
@ -147,12 +150,12 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
pageId: index, pageId: index,
isDefault: map['isDefault'] as bool ?? false, isDefault: map['isDefault'] as bool? ?? false,
mimeType: map['mimeType'] as String, mimeType: map['mimeType'] as String,
width: map['width'] as int ?? 0, width: map['width'] as int? ?? 0,
height: map['height'] as int ?? 0, height: map['height'] as int? ?? 0,
rotationDegrees: map['rotationDegrees'] as int, rotationDegrees: map['rotationDegrees'] as int?,
durationMillis: map['durationMillis'] as int, durationMillis: map['durationMillis'] as int?,
); );
} }

View file

@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class PanoramaInfo { class PanoramaInfo {
final Rect croppedAreaRect; final Rect? croppedAreaRect;
final Size fullPanoSize; final Size? fullPanoSize;
final String projectionType; final String? projectionType;
PanoramaInfo({ PanoramaInfo({
this.croppedAreaRect, this.croppedAreaRect,
@ -13,13 +13,13 @@ class PanoramaInfo {
}); });
factory PanoramaInfo.fromMap(Map map) { factory PanoramaInfo.fromMap(Map map) {
var cLeft = map['croppedAreaLeft'] as int; var cLeft = map['croppedAreaLeft'] as int?;
var cTop = map['croppedAreaTop'] as int; var cTop = map['croppedAreaTop'] as int?;
final cWidth = map['croppedAreaWidth'] as int; final cWidth = map['croppedAreaWidth'] as int?;
final cHeight = map['croppedAreaHeight'] as int; final cHeight = map['croppedAreaHeight'] as int?;
var fWidth = map['fullPanoWidth'] as int; var fWidth = map['fullPanoWidth'] as int?;
var fHeight = map['fullPanoHeight'] as int; var fHeight = map['fullPanoHeight'] as int?;
final projectionType = map['projectionType'] as String; final projectionType = map['projectionType'] as String?;
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode) // handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
if (fHeight == null && cWidth != null && cHeight != null) { if (fHeight == null && cWidth != null && cHeight != null) {
@ -31,12 +31,12 @@ class PanoramaInfo {
cLeft = 0; cLeft = 0;
} }
Rect croppedAreaRect; Rect? croppedAreaRect;
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) { if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble()); croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
} }
Size fullPanoSize; Size? fullPanoSize;
if (fWidth != null && fHeight != null) { if (fWidth != null && fHeight != null) {
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble()); fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
} }

View file

@ -15,12 +15,11 @@ extension ExtraEntryBackground on EntryBackground {
Color get color { Color get color {
switch (this) { switch (this) {
case EntryBackground.black:
return Colors.black;
case EntryBackground.white: case EntryBackground.white:
return Colors.white; return Colors.white;
case EntryBackground.black:
default: default:
return null; return Colors.black;
} }
} }
} }

View file

@ -1,6 +1,7 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:collection/collection.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
@ -14,7 +15,7 @@ import 'enums.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
class Settings extends ChangeNotifier { class Settings extends ChangeNotifier {
static SharedPreferences _prefs; static SharedPreferences? /*late final*/ _prefs;
Settings._private(); Settings._private();
@ -93,7 +94,7 @@ class Settings extends ChangeNotifier {
} }
Future<void> reset() { Future<void> reset() {
return _prefs.clear(); return _prefs!.clear();
} }
// app // app
@ -111,7 +112,7 @@ class Settings extends ChangeNotifier {
static const localeSeparator = '-'; static const localeSeparator = '-';
Locale get locale { Locale? get locale {
// exceptionally allow getting locale before settings are initialized // exceptionally allow getting locale before settings are initialized
final tag = _prefs?.getString(localeKey); final tag = _prefs?.getString(localeKey);
if (tag != null) { if (tag != null) {
@ -125,11 +126,11 @@ class Settings extends ChangeNotifier {
return null; return null;
} }
set locale(Locale newValue) { set locale(Locale? newValue) {
String tag; String? tag;
if (newValue != null) { if (newValue != null) {
tag = [ tag = [
newValue.languageCode ?? '', newValue.languageCode,
newValue.scriptCode ?? '', newValue.scriptCode ?? '',
newValue.countryCode ?? '', newValue.countryCode ?? '',
].join(localeSeparator); ].join(localeSeparator);
@ -152,11 +153,11 @@ class Settings extends ChangeNotifier {
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString()); 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); 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` // do not notify, as tile extents are only used internally by `TileExtentController`
// and should not trigger rebuilding by change notification // 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 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 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()); 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()); 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); set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
@ -272,23 +273,23 @@ class Settings extends ChangeNotifier {
set saveSearchHistory(bool newValue) => setAndNotify(saveSearchHistoryKey, newValue); 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()); set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
// version // 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); set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
// convenience methods // convenience methods
// ignore: avoid_positional_boolean_parameters // 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) { 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) { for (final v in values) {
if (v.toString() == valueString) { if (v.toString() == valueString) {
return v; return v;
@ -298,28 +299,28 @@ class Settings extends ChangeNotifier {
} }
List<T> getEnumListOrDefault<T>(String key, List<T> defaultValue, Iterable<T> values) { 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}) { void setAndNotify(String key, dynamic newValue, {bool notify = true}) {
var oldValue = _prefs.get(key); var oldValue = _prefs!.get(key);
if (newValue == null) { if (newValue == null) {
_prefs.remove(key); _prefs!.remove(key);
} else if (newValue is String) { } else if (newValue is String) {
oldValue = _prefs.getString(key); oldValue = _prefs!.getString(key);
_prefs.setString(key, newValue); _prefs!.setString(key, newValue);
} else if (newValue is List<String>) { } else if (newValue is List<String>) {
oldValue = _prefs.getStringList(key); oldValue = _prefs!.getStringList(key);
_prefs.setStringList(key, newValue); _prefs!.setStringList(key, newValue);
} else if (newValue is int) { } else if (newValue is int) {
oldValue = _prefs.getInt(key); oldValue = _prefs!.getInt(key);
_prefs.setInt(key, newValue); _prefs!.setInt(key, newValue);
} else if (newValue is double) { } else if (newValue is double) {
oldValue = _prefs.getDouble(key); oldValue = _prefs!.getDouble(key);
_prefs.setDouble(key, newValue); _prefs!.setDouble(key, newValue);
} else if (newValue is bool) { } else if (newValue is bool) {
oldValue = _prefs.getBool(key); oldValue = _prefs!.getBool(key);
_prefs.setBool(key, newValue); _prefs!.setBool(key, newValue);
} }
if (oldValue != newValue && notify) { if (oldValue != newValue && notify) {
notifyListeners(); notifyListeners();

View file

@ -25,11 +25,10 @@ extension ExtraVideoLoopMode on VideoLoopMode {
case VideoLoopMode.never: case VideoLoopMode.never:
return false; return false;
case VideoLoopMode.shortOnly: case VideoLoopMode.shortOnly:
if (entry.durationMillis == null) return false; final durationMillis = entry.durationMillis;
return entry.durationMillis < shortVideoThreshold.inMilliseconds; return durationMillis != null ? durationMillis < shortVideoThreshold.inMilliseconds : false;
case VideoLoopMode.always: case VideoLoopMode.always:
return true; return true;
} }
return false;
} }
} }

View file

@ -9,7 +9,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
mixin AlbumMixin on SourceBase { mixin AlbumMixin on SourceBase {
final Set<String> _directories = {}; final Set<String?> _directories = {};
List<String> get rawAlbums => List.unmodifiable(_directories); List<String> get rawAlbums => List.unmodifiable(_directories);
@ -25,7 +25,7 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getAlbumDisplayName(BuildContext context, String dirPath) { String getAlbumDisplayName(BuildContext? context, String dirPath) {
assert(!dirPath.endsWith(pContext.separator)); assert(!dirPath.endsWith(pContext.separator));
if (context != null) { if (context != null) {
@ -41,15 +41,15 @@ mixin AlbumMixin on SourceBase {
final relativeDir = dir.relativeDir; final relativeDir = dir.relativeDir;
if (relativeDir.isEmpty) { if (relativeDir.isEmpty) {
final volume = androidFileUtils.getStorageVolume(dirPath); final volume = androidFileUtils.getStorageVolume(dirPath)!;
return volume.getDescription(context); return volume.getDescription(context);
} }
String unique(String dirPath, Set<String> others) { String unique(String dirPath, Set<String?> others) {
final parts = pContext.split(dirPath); final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) { for (var i = parts.length - 1; i > 0; i--) {
final testName = pContext.joinAll(['', ...parts.skip(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; return dirPath;
} }
@ -61,10 +61,10 @@ mixin AlbumMixin on SourceBase {
} }
final volumePath = dir.volumePath; final volumePath = dir.volumePath;
String trimVolumePath(String path) => path.substring(dir.volumePath.length); String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet(); final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume); final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
final volume = androidFileUtils.getStorageVolume(dirPath); final volume = androidFileUtils.getStorageVolume(dirPath)!;
if (volume.isPrimary) { if (volume.isPrimary) {
return uniqueNameInVolume; return uniqueNameInVolume;
} else { } else {
@ -72,7 +72,7 @@ mixin AlbumMixin on SourceBase {
} }
} }
Map<String, AvesEntry> getAlbumEntries() { Map<String, AvesEntry?> getAlbumEntries() {
final entries = sortedEntriesByDate; final entries = sortedEntriesByDate;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[]; final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (final album in rawAlbums) { for (final album in rawAlbums) {
@ -90,7 +90,7 @@ mixin AlbumMixin on SourceBase {
} }
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) => MapEntry( return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) => MapEntry(
album, album,
entries.firstWhere((entry) => entry.directory == album, orElse: () => null), entries.firstWhereOrNull((entry) => entry.directory == album),
))); )));
} }
@ -100,14 +100,14 @@ mixin AlbumMixin on SourceBase {
cleanEmptyAlbums(); cleanEmptyAlbums();
} }
void addDirectories(Set<String> albums) { void addDirectories(Set<String?> albums) {
if (!_directories.containsAll(albums)) { if (!_directories.containsAll(albums)) {
_directories.addAll(albums); _directories.addAll(albums);
_notifyAlbumChange(); _notifyAlbumChange();
} }
} }
void cleanEmptyAlbums([Set<String> albums]) { void cleanEmptyAlbums([Set<String?>? albums]) {
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet(); final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
if (emptyAlbums.isNotEmpty) { if (emptyAlbums.isNotEmpty) {
_directories.removeAll(emptyAlbums); _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 // filter summary
// by directory // by directory
final Map<String, int> _filterEntryCountMap = {}; 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) { if (entries == null && directories == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } else {
directories ??= entries.map((entry) => entry.directory).toSet(); directories ??= entries!.map((entry) => entry.directory).toSet();
directories.forEach(_filterEntryCountMap.remove); directories.forEach(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove); directories.forEach(_filterRecentEntryMap.remove);
} }
@ -144,15 +144,15 @@ mixin AlbumMixin on SourceBase {
return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length); return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length);
} }
AvesEntry albumRecentEntry(AlbumFilter filter) { AvesEntry? albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
} }
} }
class AlbumsChangedEvent {} class AlbumsChangedEvent {}
class AlbumSummaryInvalidatedEvent { class AlbumSummaryInvalidatedEvent {
final Set<String> directories; final Set<String?>? directories;
const AlbumSummaryInvalidatedEvent(this.directories); const AlbumSummaryInvalidatedEvent(this.directories);
} }

View file

@ -18,26 +18,26 @@ import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin { class CollectionLens with ChangeNotifier, CollectionActivityMixin {
final CollectionSource source; final CollectionSource source;
final Set<CollectionFilter> filters; final Set<CollectionFilter > filters;
EntryGroupFactor groupFactor; EntryGroupFactor groupFactor;
EntrySortFactor sortFactor; EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
int id; final List<StreamSubscription> _subscriptions = [];
int? id;
bool listenToSource; bool listenToSource;
List<AvesEntry> _filteredSortedEntries; List<AvesEntry> _filteredSortedEntries = [];
List<StreamSubscription> _subscriptions = [];
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({}); Map<SectionKey, List<AvesEntry> > sections = Map.unmodifiable({});
CollectionLens({ CollectionLens({
@required this.source, required this.source,
Iterable<CollectionFilter> filters, Iterable<CollectionFilter?>? filters,
EntryGroupFactor groupFactor, EntryGroupFactor? groupFactor,
EntrySortFactor sortFactor, EntrySortFactor? sortFactor,
this.id, this.id,
this.listenToSource = true, 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, groupFactor = groupFactor ?? settings.collectionGroupFactor,
sortFactor = sortFactor ?? settings.collectionSortFactor { sortFactor = sortFactor ?? settings.collectionSortFactor {
id ??= hashCode; id ??= hashCode;
@ -61,7 +61,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
_subscriptions = null;
super.dispose(); super.dispose();
} }
@ -70,11 +69,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
int get entryCount => _filteredSortedEntries.length; int get entryCount => _filteredSortedEntries.length;
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries // 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 { List<AvesEntry > get sortedEntries {
_sortedEntries ??= List.of(sections.entries.expand((e) => e.value)); _sortedEntries ??= List.of(sections.entries.expand((kv) => kv.value));
return _sortedEntries; return _sortedEntries!;
} }
bool get showHeaders { bool get showHeaders {
@ -90,7 +89,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
} }
void addFilter(CollectionFilter filter) { void addFilter(CollectionFilter filter) {
if (filter == null || filters.contains(filter)) return; if (filters.contains(filter)) return;
if (filter.isUnique) { if (filter.isUnique) {
filters.removeWhere((old) => old.category == filter.category); filters.removeWhere((old) => old.category == filter.category);
} }
@ -99,7 +98,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
} }
void removeFilter(CollectionFilter filter) { void removeFilter(CollectionFilter filter) {
if (filter == null || !filters.contains(filter)) return; if (!filters.contains(filter)) return;
filters.remove(filter); filters.remove(filter);
onFilterChanged(); onFilterChanged();
} }
@ -156,19 +155,19 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
break; break;
case EntryGroupFactor.none: case EntryGroupFactor.none:
sections = Map.fromEntries([ sections = Map.fromEntries([
MapEntry(null, _filteredSortedEntries), MapEntry(SectionKey(), _filteredSortedEntries),
]); ]);
break; break;
} }
break; break;
case EntrySortFactor.size: case EntrySortFactor.size:
sections = Map.fromEntries([ sections = Map.fromEntries([
MapEntry(null, _filteredSortedEntries), MapEntry(SectionKey(), _filteredSortedEntries),
]); ]);
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); 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; break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);
@ -184,7 +183,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
_applyGroup(); _applyGroup();
} }
void onEntryAdded(Set<AvesEntry> entries) { void onEntryAdded(Set<AvesEntry>? entries) {
_refresh(); _refresh();
} }

View file

@ -15,6 +15,7 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -31,7 +32,7 @@ mixin SourceBase {
Stream<ProgressEvent> get progressStream => _progressStreamController.stream; 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 { 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 // TODO TLAD use `Set.unmodifiable()` when possible
Set<AvesEntry> get allEntries => Set.of(_rawEntries); Set<AvesEntry> get allEntries => Set.of(_rawEntries);
Set<AvesEntry> _visibleEntries; Set<AvesEntry>? _visibleEntries;
@override @override
Set<AvesEntry> get visibleEntries { Set<AvesEntry> get visibleEntries {
// TODO TLAD use `Set.unmodifiable()` when possible // TODO TLAD use `Set.unmodifiable()` when possible
_visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries)); _visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries));
return _visibleEntries; return _visibleEntries!;
} }
List<AvesEntry> _sortedEntriesByDate; List<AvesEntry>? _sortedEntriesByDate;
@override @override
List<AvesEntry> get sortedEntriesByDate { List<AvesEntry> get sortedEntriesByDate {
_sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate)); _sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate));
return _sortedEntriesByDate; return _sortedEntriesByDate!;
} }
List<DateMetadata> _savedDates; late List<DateMetadata> _savedDates;
Future<void> loadDates() async { Future<void> loadDates() async {
final stopwatch = Stopwatch()..start(); 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))); 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; _visibleEntries = null;
_sortedEntriesByDate = null; _sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries); invalidateAlbumFilterSummary(entries: entries);
@ -91,7 +92,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
entries.forEach((entry) { entries.forEach((entry) {
final contentId = entry.contentId; 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); _rawEntries.addAll(entries);
_invalidate(entries); _invalidate(entries);
@ -124,16 +125,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
Future<void> _moveEntry(AvesEntry entry, Map newFields) async { Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
final oldContentId = entry.contentId; final oldContentId = entry.contentId!;
final newContentId = newFields['contentId'] as int; final newContentId = newFields['contentId'] as int?;
entry.contentId = newContentId; entry.contentId = newContentId;
// `dateModifiedSecs` changes when moving entries to another directory, // `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory // but it does not change when renaming the containing directory
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int; if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
if (newFields.containsKey('path')) entry.path = newFields['path'] as String; if (newFields.containsKey('path')) entry.path = newFields['path'] as String?;
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] 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.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.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 oldFilter = AlbumFilter(sourceAlbum, null);
final pinned = settings.pinnedFilters.contains(oldFilter); final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(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( await updateAfterMove(
todoEntries: todoEntries, todoEntries: todoEntries,
copy: false, copy: false,
@ -177,37 +178,39 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
Future<void> updateAfterMove({ Future<void> updateAfterMove({
@required Set<AvesEntry> todoEntries, required Set<AvesEntry> todoEntries,
@required bool copy, required bool copy,
@required String destinationAlbum, required String destinationAlbum,
@required Set<MoveOpEvent> movedOps, required Set<MoveOpEvent> movedOps,
}) async { }) async {
if (movedOps.isEmpty) return; if (movedOps.isEmpty) return;
final fromAlbums = <String>{}; final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{}; final movedEntries = <AvesEntry>{};
if (copy) { if (copy) {
movedOps.forEach((movedOp) { movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri; final sourceUri = movedOp.uri;
final newFields = movedOp.newFields; 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); fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith( movedEntries.add(sourceEntry.copyWith(
uri: newFields['uri'] as String, uri: newFields['uri'] as String?,
path: newFields['path'] as String, path: newFields['path'] as String?,
contentId: newFields['contentId'] as int, contentId: newFields['contentId'] as int?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int, dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
)); ));
}
}); });
await metadataDb.saveEntries(movedEntries); await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata)); await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata).where((v) => v != null).cast<CatalogMetadata >().toSet());
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails)); await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).where((v) => v != null).cast<AddressDetails >().toSet());
} else { } else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async { await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final newFields = movedOp.newFields; final newFields = movedOp.newFields;
if (newFields.isNotEmpty) { if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri; 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) { if (entry != null) {
fromAlbums.add(entry.directory); fromAlbums.add(entry.directory);
movedEntries.add(entry); movedEntries.add(entry);
@ -255,17 +258,17 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return 0; return 0;
} }
AvesEntry recentEntry(CollectionFilter filter) { AvesEntry? recentEntry(CollectionFilter filter) {
if (filter is AlbumFilter) return albumRecentEntry(filter); if (filter is AlbumFilter) return albumRecentEntry(filter);
if (filter is LocationFilter) return countryRecentEntry(filter); if (filter is LocationFilter) return countryRecentEntry(filter);
if (filter is TagFilter) return tagRecentEntry(filter); if (filter is TagFilter) return tagRecentEntry(filter);
return null; return null;
} }
AvesEntry coverEntry(CollectionFilter filter) { AvesEntry? coverEntry(CollectionFilter filter) {
final contentId = covers.coverContentId(filter); final contentId = covers.coverContentId(filter);
if (contentId != null) { 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; if (entry != null) return entry;
} }
return recentEntry(filter); return recentEntry(filter);
@ -297,7 +300,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
class EntryAddedEvent { class EntryAddedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]); const EntryAddedEvent([this.entries]);
} }
@ -324,5 +327,5 @@ class FilterVisibilityChangedEvent {
class ProgressEvent { class ProgressEvent {
final int done, total; final int done, total;
const ProgressEvent({@required this.done, @required this.total}); const ProgressEvent({required this.done, required this.total});
} }

View file

@ -22,7 +22,7 @@ mixin LocationMixin on SourceBase {
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAddresses();
visibleEntries.forEach((entry) { visibleEntries.forEach((entry) {
final contentId = entry.contentId; 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'); debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onAddressMetadataChanged(); onAddressMetadataChanged();
@ -44,19 +44,19 @@ mixin LocationMixin on SourceBase {
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng).toSet()); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails >[];
todo.forEach((entry) { todo.forEach((entry) {
final position = entry.latLng; 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); entry.setCountry(countryCode);
if (entry.hasAddress) { if (entry.hasAddress) {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails!);
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(Set.of(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms'); // 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 // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
final latLngFactor = pow(10, 2); final latLngFactor = pow(10, 2);
Tuple2<int, int> approximateLatLng(AvesEntry entry) { Tuple2<int, int> approximateLatLng(AvesEntry entry) {
final lat = entry.catalogMetadata?.latitude; // entry has coordinates
final lng = entry.catalogMetadata?.longitude; final lat = entry.catalogMetadata!.latitude!;
if (lat == null || lng == null) return null; final lng = entry.catalogMetadata!.longitude!;
return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round()); return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
} }
final knownLocations = <Tuple2<int, int>, AddressDetails>{}; final knownLocations = <Tuple2<int, int>, AddressDetails?>{};
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails)); byLocated[true]?.forEach((entry) {
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
});
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locating;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails >[];
await Future.forEach<AvesEntry>(todo, (entry) async { await Future.forEach<AvesEntry>(todo, (entry) async {
final latLng = approximateLatLng(entry); final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
@ -108,9 +110,9 @@ mixin LocationMixin on SourceBase {
knownLocations[latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
} }
if (entry.hasFineAddress) { if (entry.hasFineAddress) {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails!);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(Set.of(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
newAddresses.clear(); newAddresses.clear();
} }
@ -118,7 +120,7 @@ mixin LocationMixin on SourceBase {
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(Set.of(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s'); // debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
@ -130,8 +132,8 @@ mixin LocationMixin on SourceBase {
} }
void updateLocations() { void updateLocations() {
final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).toList(); 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); 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)) { if (!listEquals(updatedPlaces, sortedPlaces)) {
sortedPlaces = List.unmodifiable(updatedPlaces); sortedPlaces = List.unmodifiable(updatedPlaces);
eventBus.fire(PlacesChangedEvent()); eventBus.fire(PlacesChangedEvent());
@ -140,7 +142,7 @@ mixin LocationMixin on SourceBase {
// the same country code could be found with different country names // the same country code could be found with different country names
// e.g. if the locale changed between geocoding calls // e.g. if the locale changed between geocoding calls
// so we merge countries by code, keeping only one name for each code // 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); final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedCountries, sortedCountries)) { if (!listEquals(updatedCountries, sortedCountries)) {
sortedCountries = List.unmodifiable(updatedCountries); sortedCountries = List.unmodifiable(updatedCountries);
@ -153,27 +155,30 @@ mixin LocationMixin on SourceBase {
// by country code // by country code
final Map<String, int> _filterEntryCountMap = {}; final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {}; final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) { void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
Set<String> countryCodes; Set<String>? countryCodes;
if (entries == null) { if (entries == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } else {
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails.countryCode).toSet(); countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails!.countryCode).where((v) => v != null).cast<String >().toSet();
countryCodes.remove(null);
countryCodes.forEach(_filterEntryCountMap.remove); countryCodes.forEach(_filterEntryCountMap.remove);
} }
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes)); eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
} }
int countryEntryCount(LocationFilter filter) { 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) { AvesEntry? countryRecentEntry(LocationFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); 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 CountriesChangedEvent {}
class CountrySummaryInvalidatedEvent { class CountrySummaryInvalidatedEvent {
final Set<String> countryCodes; final Set<String>? countryCodes;
const CountrySummaryInvalidatedEvent(this.countryCodes); const CountrySummaryInvalidatedEvent(this.countryCodes);
} }

View file

@ -10,6 +10,7 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -27,6 +28,7 @@ class MediaStoreSource extends CollectionSource {
await favourites.init(); await favourites.init();
await covers.init(); await covers.init();
final currentTimeZone = await timeService.getDefaultTimeZone(); final currentTimeZone = await timeService.getDefaultTimeZone();
if (currentTimeZone != null) {
final catalogTimeZone = settings.catalogTimeZone; final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) { if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone // 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(); await metadataDb.clearMetadataEntries();
settings.catalogTimeZone = currentTimeZone; settings.catalogTimeZone = currentTimeZone;
} }
}
await loadDates(); // 100ms for 5400 entries await loadDates(); // 100ms for 5400 entries
_initialized = true; _initialized = true;
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
@ -49,7 +52,7 @@ class MediaStoreSource extends CollectionSource {
clearEntries(); clearEntries();
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries 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(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
@ -63,7 +66,7 @@ class MediaStoreSource extends CollectionSource {
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false); await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
// verify paths because some apps move files without updating their `last modified date` // 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(); final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
movedContentIds.forEach((contentId) { movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date // make obsolete by resetting its modified date
@ -130,8 +133,8 @@ class MediaStoreSource extends CollectionSource {
Future<Set<String>> refreshUris(Set<String> changedUris) async { Future<Set<String>> refreshUris(Set<String> changedUris) async {
if (!_initialized || !isMonitoring) return changedUris; if (!_initialized || !isMonitoring) return changedUris;
final uriByContentId = Map.fromEntries(changedUris.map((uri) { final uriByContentId = Map.fromEntries(changedUris
if (uri == null) return null; .map((uri) {
final pathSegments = Uri.parse(uri).pathSegments; final pathSegments = Uri.parse(uri).pathSegments;
// e.g. URI `content://media/` has no path segment // e.g. URI `content://media/` has no path segment
if (pathSegments.isEmpty) return null; if (pathSegments.isEmpty) return null;
@ -139,11 +142,13 @@ class MediaStoreSource extends CollectionSource {
final contentId = int.tryParse(idString); final contentId = int.tryParse(idString);
if (contentId == null) return null; if (contentId == null) return null;
return MapEntry(contentId, uri); return MapEntry(contentId, uri);
}).where((kv) => kv != null)); })
.where((kv) => kv != null)
.cast<MapEntry<int, String>>());
// clean up obsolete entries // clean up obsolete entries
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); 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); await removeEntries(obsoleteUris);
obsoleteContentIds.forEach(uriByContentId.remove); obsoleteContentIds.forEach(uriByContentId.remove);
@ -156,14 +161,16 @@ class MediaStoreSource extends CollectionSource {
final uri = kv.value; final uri = kv.value;
final sourceEntry = await imageFileService.getEntry(uri, null); final sourceEntry = await imageFileService.getEntry(uri, null);
if (sourceEntry != 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` // compare paths because some apps move files without updating their `last modified date`
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs || sourceEntry.path != existingEntry.path) { if (existingEntry == null || (sourceEntry.dateModifiedSecs ?? 0) > (existingEntry.dateModifiedSecs ?? 0) || sourceEntry.path != existingEntry.path) {
final volume = androidFileUtils.getStorageVolume(sourceEntry.path); final newPath = sourceEntry.path;
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
if (volume != null) { if (volume != null) {
newEntries.add(sourceEntry); newEntries.add(sourceEntry);
if (existingEntry != null) { final existingDirectory = existingEntry?.directory;
existingDirectories.add(existingEntry.directory); if (existingDirectory != null) {
existingDirectories.add(existingDirectory);
} }
} else { } else {
debugPrint('$runtimeType refreshUris entry=$sourceEntry is not located on a known storage volume. Will retry soon...'); 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 @override
Future<void> refreshMetadata(Set<AvesEntry> entries) { Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet(); final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, metadataOnly: true); metadataDb.removeIds(contentIds as Set<int>, metadataOnly: true);
return refresh(); return refresh();
} }
} }

View file

@ -5,7 +5,7 @@ class SectionKey {
} }
class EntryAlbumSectionKey extends SectionKey { class EntryAlbumSectionKey extends SectionKey {
final String directory; final String? directory;
const EntryAlbumSectionKey(this.directory); const EntryAlbumSectionKey(this.directory);
@ -23,7 +23,7 @@ class EntryAlbumSectionKey extends SectionKey {
} }
class EntryDateSectionKey extends SectionKey { class EntryDateSectionKey extends SectionKey {
final DateTime date; final DateTime? date;
const EntryDateSectionKey(this.date); const EntryDateSectionKey(this.date);

View file

@ -17,7 +17,7 @@ mixin TagMixin on SourceBase {
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadMetadataEntries();
visibleEntries.forEach((entry) { visibleEntries.forEach((entry) {
final contentId = entry.contentId; 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'); debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onCatalogMetadataChanged(); onCatalogMetadataChanged();
@ -37,16 +37,16 @@ mixin TagMixin on SourceBase {
await Future.forEach<AvesEntry>(todo, (entry) async { await Future.forEach<AvesEntry>(todo, (entry) async {
await entry.catalog(background: true); await entry.catalog(background: true);
if (entry.isCatalogued) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata); newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= _commitCountThreshold) { if (newMetadata.length >= _commitCountThreshold) {
await metadataDb.saveMetadata(List.unmodifiable(newMetadata)); await metadataDb.saveMetadata(Set.of(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
newMetadata.clear(); newMetadata.clear();
} }
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
await metadataDb.saveMetadata(List.unmodifiable(newMetadata)); await metadataDb.saveMetadata(Set.of(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s'); // debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
} }
@ -69,10 +69,10 @@ mixin TagMixin on SourceBase {
// by tag // by tag
final Map<String, int> _filterEntryCountMap = {}; final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {}; final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry> entries]) { void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
Set<String> tags; Set<String>? tags;
if (entries == null) { if (entries == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
@ -87,8 +87,8 @@ mixin TagMixin on SourceBase {
return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where(filter.test).length); return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where(filter.test).length);
} }
AvesEntry tagRecentEntry(TagFilter filter) { AvesEntry? tagRecentEntry(TagFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
} }
} }
@ -97,7 +97,7 @@ class CatalogMetadataChangedEvent {}
class TagsChangedEvent {} class TagsChangedEvent {}
class TagSummaryInvalidatedEvent { class TagSummaryInvalidatedEvent {
final Set<String> tags; final Set<String>? tags;
const TagSummaryInvalidatedEvent(this.tags); const TagSummaryInvalidatedEvent(this.tags);
} }

View file

@ -11,6 +11,8 @@ import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.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:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -54,16 +56,16 @@ class VideoMetadataFormatter {
final value = kv.value; final value = kv.value;
if (value != null) { if (value != null) {
try { try {
String key; String? key;
String keyLanguage; String? keyLanguage;
// some keys have a language suffix, but they may be duplicates // 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 // 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); final languageMatch = keyWithLanguagePattern.firstMatch(kv.key);
if (languageMatch != null) { if (languageMatch != null) {
final code = languageMatch.group(2); final code = languageMatch.group(2)!;
final native = _formatLanguage(code); final native = _formatLanguage(code);
if (native != code) { if (native != code) {
final root = languageMatch.group(1); final root = languageMatch.group(1)!;
final rootValue = info[root]; final rootValue = info[root];
// skip if it is a duplicate of the same entry with no language // skip if it is a duplicate of the same entry with no language
if (rootValue == value) continue; if (rootValue == value) continue;
@ -76,7 +78,7 @@ class VideoMetadataFormatter {
} }
key = (key ?? (kv.key as String)).toLowerCase(); key = (key ?? (kv.key as String)).toLowerCase();
void save(String key, String value) { void save(String key, String? value) {
if (value != null) { if (value != null) {
dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value; dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value;
} }
@ -129,21 +131,26 @@ class VideoMetadataFormatter {
case Keys.codecProfileId: case Keys.codecProfileId:
if (codec == 'h264') { if (codec == 'h264') {
final profile = int.tryParse(value); final profile = int.tryParse(value);
if (profile != null && profile != 0) { final levelString = info[Keys.codecLevel];
final level = int.tryParse(info[Keys.codecLevel]); if (profile != null && profile != 0 && levelString != null) {
final level = int.tryParse(levelString) ?? 0;
save('Codec Profile', H264.formatProfile(profile, level)); save('Codec Profile', H264.formatProfile(profile, level));
} }
} }
break; break;
case Keys.compatibleBrands: 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; break;
case Keys.creationTime: case Keys.creationTime:
save('Creation Time', _formatDate(value)); save('Creation Time', _formatDate(value));
break; break;
case Keys.date: case Keys.date:
if (value != '0') { if (value is String && value != '0') {
final charCount = (value as String)?.length ?? 0; final charCount = value.length;
save(charCount == 4 ? 'Year' : 'Date', value); save(charCount == 4 ? 'Year' : 'Date', value);
} }
break; break;
@ -222,10 +229,10 @@ class VideoMetadataFormatter {
static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)'; 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' // input example: '2021-04-12T09:14:37.000000Z'
static String _formatDate(String value) { static String? _formatDate(String value) {
final date = DateTime.tryParse(value); final date = DateTime.tryParse(value);
if (date == null) return value; if (date == null) return value;
if (date == _epoch) return null; if (date == _epoch) return null;
@ -236,10 +243,10 @@ class VideoMetadataFormatter {
static String _formatDuration(String value) { static String _formatDuration(String value) {
final match = _durationPattern.firstMatch(value); final match = _durationPattern.firstMatch(value);
if (match != null) { if (match != null) {
final h = int.tryParse(match.group(1)); final h = int.tryParse(match.group(1)!);
final m = int.tryParse(match.group(2)); final m = int.tryParse(match.group(2)!);
final s = int.tryParse(match.group(3)); final s = int.tryParse(match.group(3)!);
final millis = double.tryParse(match.group(4)); final millis = double.tryParse(match.group(4)!);
if (h != null && m != null && s != null && millis != null) { if (h != null && m != null && s != null && millis != null) {
return formatPreciseDuration(Duration( return formatPreciseDuration(Duration(
hours: h, hours: h,
@ -258,15 +265,15 @@ class VideoMetadataFormatter {
} }
static String _formatLanguage(String value) { 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; return language?.native ?? value;
} }
// format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple) // 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); final matches = _locationPattern.allMatches(value);
if (matches.isNotEmpty) { 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; if (coordinates.every((c) => c == 0)) return null;
return coordinates.join(', '); return coordinates.join(', ');
} }

View file

@ -7,8 +7,7 @@ class BrandColors {
static const Color android = Color(0xFF3DDC84); static const Color android = Color(0xFF3DDC84);
static const Color flutter = Color(0xFF47D1FD); static const Color flutter = Color(0xFF47D1FD);
static Color get(String text) { static Color? get(String text) {
if (text != null) {
switch (text.toLowerCase()) { switch (text.toLowerCase()) {
case 'after effects': case 'after effects':
return adobeAfterEffects; return adobeAfterEffects;
@ -18,7 +17,6 @@ class BrandColors {
case 'lightroom': case 'lightroom':
return adobePhotoshop; return adobePhotoshop;
} }
}
return null; return null;
} }
} }

View file

@ -133,7 +133,7 @@ class Exif {
} }
static String getExifVersionDescription(String valueString) { static String getExifVersionDescription(String valueString) {
if (valueString?.length == 4) { if (valueString.length == 4) {
final major = int.tryParse(valueString.substring(0, 2)); final major = int.tryParse(valueString.substring(0, 2));
final minor = int.tryParse(valueString.substring(2, 4)); final minor = int.tryParse(valueString.substring(2, 4));
if (major != null && minor != null) { if (major != null && minor != null) {

View file

@ -1,9 +1,10 @@
class Language { class Language {
final String iso639_2, name, native; final String iso639_2, name;
final String? native;
const Language({ const Language({
this.iso639_2, required this.iso639_2,
this.name, required this.name,
this.native, this.native,
}); });

View file

@ -14,7 +14,7 @@ class AndroidAppService {
final result = await platform.invokeMethod('getPackages'); final result = await platform.invokeMethod('getPackages');
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet(); final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
// additional info for known directories // 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) { if (kakaoTalk != null) {
kakaoTalk.ownedDirs.add('KakaoTalkDownload'); kakaoTalk.ownedDirs.add('KakaoTalkDownload');
} }
@ -31,19 +31,20 @@ class AndroidAppService {
'packageName': packageName, 'packageName': packageName,
'sizeDip': size, 'sizeDip': size,
}); });
return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 { static Future<bool> edit(String uri, String mimeType) async {
try { try {
return await platform.invokeMethod('edit', <String, dynamic>{ final result = await platform.invokeMethod('edit', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 { static Future<bool> open(String uri, String mimeType) async {
try { try {
return await platform.invokeMethod('open', <String, dynamic>{ final result = await platform.invokeMethod('open', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 { static Future<bool> openMap(String geoUri) async {
try { try {
return await platform.invokeMethod('openMap', <String, dynamic>{ final result = await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri, 'geoUri': geoUri,
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 { static Future<bool> setAs(String uri, String mimeType) async {
try { try {
return await platform.invokeMethod('setAs', <String, dynamic>{ final result = await platform.invokeMethod('setAs', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 // 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())); final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try { try {
return await platform.invokeMethod('share', <String, dynamic>{ final result = await platform.invokeMethod('share', <String, dynamic>{
'urisByMimeType': urisByMimeType, 'urisByMimeType': urisByMimeType,
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 { static Future<bool> shareSingle(String uri, String mimeType) async {
try { try {
return await platform.invokeMethod('share', <String, dynamic>{ final result = await platform.invokeMethod('share', <String, dynamic>{
'urisByMimeType': { 'urisByMimeType': {
mimeType: [uri] mimeType: [uri]
}, },
}); });
if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }

View file

@ -9,7 +9,7 @@ class AndroidDebugService {
static Future<Map> getContextDirs() async { static Future<Map> getContextDirs() async {
try { try {
final result = await platform.invokeMethod('getContextDirs'); final result = await platform.invokeMethod('getContextDirs');
return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
@ -19,7 +19,7 @@ class AndroidDebugService {
static Future<Map> getEnv() async { static Future<Map> getEnv() async {
try { try {
final result = await platform.invokeMethod('getEnv'); final result = await platform.invokeMethod('getEnv');
return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); 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` // returns map with all data available when decoding image bounds with `BitmapFactory`
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{ final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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>{ final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -60,8 +60,8 @@ class AndroidDebugService {
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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` // returns map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -88,8 +88,8 @@ class AndroidDebugService {
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -102,8 +102,8 @@ class AndroidDebugService {
try { try {
final result = await platform.invokeMethod('getTiffStructure', <String, dynamic>{ final result = await platform.invokeMethod('getTiffStructure', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }

View file

@ -10,24 +10,27 @@ class AppShortcutService {
static const platform = MethodChannel('deckers.thibault/aves/shortcut'); static const platform = MethodChannel('deckers.thibault/aves/shortcut');
// this ability will not change over the lifetime of the app // 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) { if (_canPin != null) {
return SynchronousFuture(_canPin); return SynchronousFuture(_canPin!);
} }
try { try {
_canPin = await platform.invokeMethod('canPin'); final result = await platform.invokeMethod('canPin');
return _canPin; if (result != null) {
_canPin = result;
return result;
}
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return false; return false;
} }
static Future<void> pin(String label, AvesEntry entry, Set<CollectionFilter> filters) async { static Future<void> pin(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
Uint8List iconBytes; Uint8List? iconBytes;
if (entry != null) { if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0; final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await imageFileService.getThumbnail( iconBytes = await imageFileService.getThumbnail(
@ -44,7 +47,7 @@ class AppShortcutService {
await platform.invokeMethod('pin', <String, dynamic>{ await platform.invokeMethod('pin', <String, dynamic>{
'label': label, 'label': label,
'iconBytes': iconBytes, 'iconBytes': iconBytes,
'filters': filters.where((filter) => filter != null).map((filter) => filter.toJson()).toList(), 'filters': filters.map((filter) => filter.toJson()).toList(),
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');

View file

@ -11,7 +11,7 @@ abstract class EmbeddedDataService {
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry); 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 { class PlatformEmbeddedDataService implements EmbeddedDataService {
@ -25,7 +25,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}); });
return (result as List).cast<Uint8List>(); if (result != null) return (result as List).cast<Uint8List>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -41,11 +41,11 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
'displayName': '${entry.bestTitle} • Video', 'displayName': '${entry.bestTitle} • Video',
}); });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return {};
} }
@override @override
@ -55,15 +55,15 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'uri': entry.uri, 'uri': entry.uri,
'displayName': '${entry.bestTitle} • Cover', 'displayName': '${entry.bestTitle} • Cover',
}); });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return {};
} }
@override @override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { Future<Map> extractXmpDataProp(AvesEntry entry, String? propPath, String? propMimeType) async {
try { try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{ final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -73,10 +73,10 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'propPath': propPath, 'propPath': propPath,
'propMimeType': propMimeType, 'propMimeType': propMimeType,
}); });
return result; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return {};
} }
} }

View file

@ -29,7 +29,7 @@ class GeocodingService {
@immutable @immutable
class Address { 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({ const Address({
this.addressLine, this.addressLine,

View file

@ -7,28 +7,31 @@ import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class ImageFileService { abstract class ImageFileService {
Future<AvesEntry> getEntry(String uri, String mimeType); Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg( Future<Uint8List> getSvg(
String uri, String uri,
String mimeType, { String mimeType, {
int expectedContentLength, int? expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback? onBytesReceived,
}); });
Future<Uint8List> getImage( Future<Uint8List> getImage(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int? rotationDegrees,
bool isFlipped, { bool isFlipped, {
int pageId, int? pageId,
int expectedContentLength, int? expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback? onBytesReceived,
}); });
// `rect`: region to decode, with coordinates in reference to `imageSize` // `rect`: region to decode, with coordinates in reference to `imageSize`
@ -40,21 +43,21 @@ abstract class ImageFileService {
int sampleSize, int sampleSize,
Rectangle<int> regionRect, Rectangle<int> regionRect,
Size imageSize, { Size imageSize, {
int pageId, int? pageId,
Object taskKey, Object? taskKey,
int priority, int? priority,
}); });
Future<Uint8List> getThumbnail({ Future<Uint8List> getThumbnail({
@required String uri, required String uri,
@required String mimeType, required String mimeType,
@required int rotationDegrees, required int rotationDegrees,
@required int pageId, required int? pageId,
@required bool isFlipped, required bool isFlipped,
@required int dateModifiedSecs, required int? dateModifiedSecs,
@required double extent, required double extent,
Object taskKey, Object? taskKey,
int priority, int? priority,
}); });
Future<void> clearSizedThumbnailDiskCache(); Future<void> clearSizedThumbnailDiskCache();
@ -63,27 +66,27 @@ abstract class ImageFileService {
bool cancelThumbnail(Object taskKey); bool cancelThumbnail(Object taskKey);
Future<T> resumeLoading<T>(Object taskKey); Future<T>? resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries); Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
Stream<MoveOpEvent> move( Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required bool copy, required bool copy,
@required String destinationAlbum, required String destinationAlbum,
}); });
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required String mimeType, required String mimeType,
@required String destinationAlbum, 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 { class PlatformImageFileService implements ImageFileService {
@ -108,7 +111,7 @@ class PlatformImageFileService implements ImageFileService {
} }
@override @override
Future<AvesEntry> getEntry(String uri, String mimeType) async { Future<AvesEntry?> getEntry(String uri, String? mimeType) async {
try { try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{ final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -125,8 +128,8 @@ class PlatformImageFileService implements ImageFileService {
Future<Uint8List> getSvg( Future<Uint8List> getSvg(
String uri, String uri,
String mimeType, { String mimeType, {
int expectedContentLength, int? expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback? onBytesReceived,
}) => }) =>
getImage( getImage(
uri, uri,
@ -141,11 +144,11 @@ class PlatformImageFileService implements ImageFileService {
Future<Uint8List> getImage( Future<Uint8List> getImage(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int? rotationDegrees,
bool isFlipped, { bool isFlipped, {
int pageId, int? pageId,
int expectedContentLength, int? expectedContentLength,
BytesReceivedCallback onBytesReceived, BytesReceivedCallback? onBytesReceived,
}) { }) {
try { try {
final completer = Completer<Uint8List>.sync(); final completer = Completer<Uint8List>.sync();
@ -155,7 +158,7 @@ class PlatformImageFileService implements ImageFileService {
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0, 'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false, 'isFlipped': isFlipped,
'pageId': pageId, 'pageId': pageId,
}).listen( }).listen(
(data) { (data) {
@ -182,7 +185,7 @@ class PlatformImageFileService implements ImageFileService {
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return Future.sync(() => null); return Future.sync(() => Uint8List(0));
} }
@override @override
@ -194,9 +197,9 @@ class PlatformImageFileService implements ImageFileService {
int sampleSize, int sampleSize,
Rectangle<int> regionRect, Rectangle<int> regionRect,
Size imageSize, { Size imageSize, {
int pageId, int? pageId,
Object taskKey, Object? taskKey,
int priority, int? priority,
}) { }) {
return servicePolicy.call( return servicePolicy.call(
() async { () async {
@ -213,11 +216,11 @@ class PlatformImageFileService implements ImageFileService {
'imageWidth': imageSize.width.toInt(), 'imageWidth': imageSize.width.toInt(),
'imageHeight': imageSize.height.toInt(), 'imageHeight': imageSize.height.toInt(),
}); });
return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return Uint8List(0);
}, },
priority: priority ?? ServiceCallPriority.getRegion, priority: priority ?? ServiceCallPriority.getRegion,
key: taskKey, key: taskKey,
@ -226,18 +229,18 @@ class PlatformImageFileService implements ImageFileService {
@override @override
Future<Uint8List> getThumbnail({ Future<Uint8List> getThumbnail({
@required String uri, required String uri,
@required String mimeType, required String mimeType,
@required int rotationDegrees, required int rotationDegrees,
@required int pageId, required int? pageId,
@required bool isFlipped, required bool isFlipped,
@required int dateModifiedSecs, required int? dateModifiedSecs,
@required double extent, required double extent,
Object taskKey, Object? taskKey,
int priority, int? priority,
}) { }) {
if (mimeType == MimeTypes.svg) { if (mimeType == MimeTypes.svg) {
return Future.sync(() => null); return Future.sync(() => Uint8List(0));
} }
return servicePolicy.call( return servicePolicy.call(
() async { () async {
@ -253,11 +256,11 @@ class PlatformImageFileService implements ImageFileService {
'pageId': pageId, 'pageId': pageId,
'defaultSizeDip': thumbnailDefaultSize, 'defaultSizeDip': thumbnailDefaultSize,
}); });
return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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), priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
key: taskKey, key: taskKey,
@ -280,7 +283,7 @@ class PlatformImageFileService implements ImageFileService {
bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
@override @override
Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey); Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override @override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) { Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
@ -298,8 +301,8 @@ class PlatformImageFileService implements ImageFileService {
@override @override
Stream<MoveOpEvent> move( Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required bool copy, required bool copy,
@required String destinationAlbum, required String destinationAlbum,
}) { }) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -317,8 +320,8 @@ class PlatformImageFileService implements ImageFileService {
@override @override
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required String mimeType, required String mimeType,
@required String destinationAlbum, required String destinationAlbum,
}) { }) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -334,14 +337,14 @@ class PlatformImageFileService implements ImageFileService {
} }
@override @override
Future<Map> rename(AvesEntry entry, String newName) async { Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
try { try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional) // returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{ final result = await platform.invokeMethod('rename', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'newName': newName, 'newName': newName,
}) as Map; });
return result; if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -349,14 +352,14 @@ class PlatformImageFileService implements ImageFileService {
} }
@override @override
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async { Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise, 'clockwise': clockwise,
}) as Map; });
return result; if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -364,13 +367,13 @@ class PlatformImageFileService implements ImageFileService {
} }
@override @override
Future<Map> flip(AvesEntry entry) async { Future<Map<String, dynamic>> flip(AvesEntry entry) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{ final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
}) as Map; });
return result; if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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` // cf flutter/foundation `consolidateHttpClientResponseBytes`
typedef BytesReceivedCallback = void Function(int cumulative, int total); typedef BytesReceivedCallback = void Function(int cumulative, int? total);
// cf flutter/foundation `consolidateHttpClientResponseBytes` // cf flutter/foundation `consolidateHttpClientResponseBytes`
class _OutputBuffer extends ByteConversionSinkBase { class _OutputBuffer extends ByteConversionSinkBase {
List<List<int>> _chunks = <List<int>>[]; List<List<int>>? _chunks = <List<int>>[];
int _contentLength = 0; int _contentLength = 0;
Uint8List _bytes; Uint8List? _bytes;
@override @override
void add(List<int> chunk) { void add(List<int> chunk) {
assert(_bytes == null); assert(_bytes == null);
_chunks.add(chunk); _chunks!.add(chunk);
_contentLength += chunk.length; _contentLength += chunk.length;
} }
@ -402,8 +405,8 @@ class _OutputBuffer extends ByteConversionSinkBase {
} }
_bytes = Uint8List(_contentLength); _bytes = Uint8List(_contentLength);
var offset = 0; var offset = 0;
for (final chunk in _chunks) { for (final chunk in _chunks!) {
_bytes.setRange(offset, offset + chunk.length, chunk); _bytes!.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length; offset += chunk.length;
} }
_chunks = null; _chunks = null;
@ -411,6 +414,6 @@ class _OutputBuffer extends ByteConversionSinkBase {
Uint8List get bytes { Uint8List get bytes {
assert(_bytes != null); assert(_bytes != null);
return _bytes; return _bytes!;
} }
} }

View file

@ -7,8 +7,8 @@ class ImageOpEvent {
final String uri; final String uri;
const ImageOpEvent({ const ImageOpEvent({
this.success, required this.success,
this.uri, required this.uri,
}); });
factory ImageOpEvent.fromMap(Map map) { factory ImageOpEvent.fromMap(Map map) {
@ -34,7 +34,7 @@ class ImageOpEvent {
class MoveOpEvent extends ImageOpEvent { class MoveOpEvent extends ImageOpEvent {
final Map newFields; final Map newFields;
const MoveOpEvent({bool success, String uri, this.newFields}) const MoveOpEvent({required bool success, required String uri, required this.newFields})
: super( : super(
success: success, success: success,
uri: uri, uri: uri,
@ -44,7 +44,7 @@ class MoveOpEvent extends ImageOpEvent {
return MoveOpEvent( return MoveOpEvent(
success: map['success'] ?? false, success: map['success'] ?? false,
uri: map['uri'], uri: map['uri'],
newFields: map['newFields'], newFields: map['newFields'] ?? {},
); );
} }
@ -53,9 +53,9 @@ class MoveOpEvent extends ImageOpEvent {
} }
class ExportOpEvent extends MoveOpEvent { 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( : super(
success: success, success: success,
uri: uri, uri: uri,
@ -67,7 +67,7 @@ class ExportOpEvent extends MoveOpEvent {
success: map['success'] ?? false, success: map['success'] ?? false,
uri: map['uri'], uri: map['uri'],
pageId: map['pageId'], pageId: map['pageId'],
newFields: map['newFields'], newFields: map['newFields'] ?? {},
); );
} }

View file

@ -3,12 +3,13 @@ import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class MediaStoreService { abstract class MediaStoreService {
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds); 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 // knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries); Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
@ -32,7 +33,7 @@ class PlatformMediaStoreService implements MediaStoreService {
} }
@override @override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async { Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById) async {
try { try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{ final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById, 'knownPathById': knownPathById,

View file

@ -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) // 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<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 { class PlatformMetadataService implements MetadataService {
@ -26,7 +26,7 @@ class PlatformMetadataService implements MetadataService {
@override @override
Future<Map> getAllMetadata(AvesEntry entry) async { Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return {};
try { try {
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
@ -34,7 +34,7 @@ class PlatformMetadataService implements MetadataService {
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}); });
return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
@ -42,10 +42,10 @@ class PlatformMetadataService implements MetadataService {
} }
@override @override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async { Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
Future<CatalogMetadata> call() async { Future<CatalogMetadata?> call() async {
try { try {
// returns map with: // returns map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
@ -80,7 +80,7 @@ class PlatformMetadataService implements MetadataService {
} }
@override @override
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async { Future<OverlayMetadata?> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -98,7 +98,7 @@ class PlatformMetadataService implements MetadataService {
} }
@override @override
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async { Future<MultiPageInfo?> getMultiPageInfo(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{ final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -120,7 +120,7 @@ class PlatformMetadataService implements MetadataService {
} }
@override @override
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async { Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry) async {
try { try {
// returns map with values for: // returns map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
@ -138,7 +138,7 @@ class PlatformMetadataService implements MetadataService {
} }
@override @override
Future<String> getContentResolverProp(AvesEntry entry, String prop) async { Future<String?> getContentResolverProp(AvesEntry entry, String prop) async {
try { try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{ return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -22,19 +23,19 @@ class ServicePolicy {
Future<T> call<T>( Future<T> call<T>(
Future<T> Function() platformCall, { Future<T> Function() platformCall, {
int priority = ServiceCallPriority.normal, int priority = ServiceCallPriority.normal,
Object key, Object? key,
}) { }) {
Completer<T> completer; Completer<T> completer;
_Task task; _Task<T > task;
key ??= platformCall.hashCode; key ??= platformCall.hashCode;
final toResume = _paused.remove(key); final toResume = _paused.remove(key);
if (toResume != null) { if (toResume != null) {
priority = toResume.item1; priority = toResume.item1;
task = toResume.item2; task = toResume.item2 as _Task<T>;
completer = task.completer; completer = task.completer;
} else { } else {
completer = Completer<T>(); completer = Completer<T>();
task = _Task( task = _Task<T>(
() async { () async {
try { try {
completer.complete(await platformCall()); completer.complete(await platformCall());
@ -52,11 +53,11 @@ class ServicePolicy {
return completer.future; return completer.future;
} }
Future<T> resume<T>(Object key) { Future<T>? resume<T>(Object key) {
final toResume = _paused.remove(key); final toResume = _paused.remove(key);
if (toResume != null) { if (toResume != null) {
final priority = toResume.item1; final priority = toResume.item1;
final task = toResume.item2; final task = toResume.item2 as _Task<T >;
_getQueue(priority)[key] = task; _getQueue(priority)[key] = task;
_pickNext(); _pickNext();
return task.completer.future; return task.completer.future;
@ -70,10 +71,10 @@ class ServicePolicy {
void _pickNext() { void _pickNext() {
_notifyQueueState(); _notifyQueueState();
if (_runningQueue.length >= concurrentTaskMax) return; 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) { if (queue != null && queue.isNotEmpty) {
final key = queue.keys.first; final key = queue.keys.first;
final task = queue.remove(key); final task = queue.remove(key)!;
_runningQueue[key] = task; _runningQueue[key] = task;
task.callback(); task.callback();
} }
@ -109,9 +110,9 @@ class ServicePolicy {
} }
} }
class _Task { class _Task<T> {
final VoidCallback callback; final VoidCallback callback;
final Completer completer; final Completer<T> completer;
const _Task(this.callback, this.completer); const _Task(this.callback, this.completer);
} }

View file

@ -11,16 +11,16 @@ import 'package:path/path.dart' as p;
final getIt = GetIt.instance; final getIt = GetIt.instance;
final pContext = getIt<p.Context>(); final p.Context pContext = getIt<p.Context>();
final availability = getIt<AvesAvailability>(); final AvesAvailability availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>(); final MetadataDb metadataDb = getIt<MetadataDb>();
final embeddedDataService = getIt<EmbeddedDataService>(); final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final imageFileService = getIt<ImageFileService>(); final ImageFileService imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>(); final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>(); final MetadataService metadataService = getIt<MetadataService>();
final storageService = getIt<StorageService>(); final StorageService storageService = getIt<StorageService>();
final timeService = getIt<TimeService>(); final TimeService timeService = getIt<TimeService>();
void initPlatformServices() { void initPlatformServices() {
getIt.registerLazySingleton<p.Context>(() => p.Context()); getIt.registerLazySingleton<p.Context>(() => p.Context());

View file

@ -3,12 +3,13 @@ import 'dart:async';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class StorageService { abstract class StorageService {
Future<Set<StorageVolume>> getStorageVolumes(); Future<Set<StorageVolume>> getStorageVolumes();
Future<int> getFreeSpace(StorageVolume volume); Future<int?> getFreeSpace(StorageVolume volume);
Future<List<String>> getGrantedDirectories(); Future<List<String>> getGrantedDirectories();
@ -25,7 +26,7 @@ abstract class StorageService {
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths); Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns media URI // returns media URI
Future<Uri> scanFile(String path, String mimeType); Future<Uri?> scanFile(String path, String mimeType);
} }
class PlatformStorageService implements StorageService { class PlatformStorageService implements StorageService {
@ -44,16 +45,16 @@ class PlatformStorageService implements StorageService {
} }
@override @override
Future<int> getFreeSpace(StorageVolume volume) async { Future<int?> getFreeSpace(StorageVolume volume) async {
try { try {
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{ final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
'path': volume.path, 'path': volume.path,
}); });
return result as int; return result as int?;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return 0; return null;
} }
@override @override
@ -85,22 +86,26 @@ class PlatformStorageService implements StorageService {
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{ final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(), '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) { } on PlatformException catch (e) {
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return null; return {};
} }
@override @override
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async { Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
try { try {
final result = await platform.invokeMethod('getRestrictedDirectories'); 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) { } on PlatformException catch (e) {
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); 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` // returns whether user granted access to volume root at `volumePath`
@ -111,7 +116,7 @@ class PlatformStorageService implements StorageService {
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'path': volumePath, 'path': volumePath,
}).listen( }).listen(
(data) => completer.complete(data as bool), (data) => completer.complete(data as bool?),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
if (!completer.isCompleted) completer.complete(false); if (!completer.isCompleted) completer.complete(false);
@ -129,9 +134,10 @@ class PlatformStorageService implements StorageService {
@override @override
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async { Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
try { try {
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{ final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(), 'dirPaths': dirPaths.toList(),
}); });
if (result != null) return result as int;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
@ -140,14 +146,14 @@ class PlatformStorageService implements StorageService {
// returns media URI // returns media URI
@override @override
Future<Uri> scanFile(String path, String mimeType) async { Future<Uri?> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType'); debugPrint('scanFile with path=$path, mimeType=$mimeType');
try { try {
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{ final result = await platform.invokeMethod('scanFile', <String, dynamic>{
'path': path, 'path': path,
'mimeType': mimeType, 'mimeType': mimeType,
}); });
return Uri.tryParse(uriString ?? ''); if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
@ -15,15 +16,15 @@ class SvgMetadataService {
static const _textElements = ['title', 'desc']; static const _textElements = ['title', 'desc'];
static const _metadataElement = 'metadata'; static const _metadataElement = 'metadata';
static Future<Size> getSize(AvesEntry entry) async { static Future<Size?> getSize(AvesEntry entry) async {
try { try {
final data = await imageFileService.getSvg(entry.uri, entry.mimeType); final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data)); final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;
String getAttribute(String attributeName) => root.attributes.firstWhere((a) => a.name.qualified == attributeName, orElse: () => null)?.value; 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%]'), '')); double? tryParseWithoutUnit(String? s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), ''));
final width = tryParseWithoutUnit(getAttribute('width')); final width = tryParseWithoutUnit(getAttribute('width'));
final height = tryParseWithoutUnit(getAttribute('height')); final height = tryParseWithoutUnit(getAttribute('height'));
@ -37,7 +38,7 @@ class SvgMetadataService {
if (parts.length == 4) { if (parts.length == 4) {
final vbWidth = tryParseWithoutUnit(parts[2]); final vbWidth = tryParseWithoutUnit(parts[2]);
final vbHeight = tryParseWithoutUnit(parts[3]); final vbHeight = tryParseWithoutUnit(parts[3]);
if (vbWidth > 0 && vbHeight > 0) { if (vbWidth != null && vbWidth > 0 && vbHeight != null && vbHeight > 0) {
return Size(vbWidth, vbHeight); return Size(vbWidth, vbHeight);
} }
} }
@ -66,7 +67,7 @@ class SvgMetadataService {
final docDir = Map.fromEntries([ final docDir = Map.fromEntries([
...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(formatKey(a.name.qualified), a.value)), ...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); final metadata = root.getElement(_metadataElement);
@ -80,7 +81,7 @@ class SvgMetadataService {
}; };
} catch (error, stack) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with error=$error\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
return null;
} }
return {};
} }
} }

View file

@ -2,14 +2,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class TimeService { abstract class TimeService {
Future<String> getDefaultTimeZone(); Future<String?> getDefaultTimeZone();
} }
class PlatformTimeService implements TimeService { class PlatformTimeService implements TimeService {
static const platform = MethodChannel('deckers.thibault/aves/time'); static const platform = MethodChannel('deckers.thibault/aves/time');
@override @override
Future<String> getDefaultTimeZone() async { Future<String?> getDefaultTimeZone() async {
try { try {
return await platform.invokeMethod('getDefaultTimeZone'); return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) { } on PlatformException catch (e) {

View file

@ -4,10 +4,11 @@ import 'package:flutter/services.dart';
class ViewerService { class ViewerService {
static const platform = MethodChannel('deckers.thibault/aves/viewer'); static const platform = MethodChannel('deckers.thibault/aves/viewer');
static Future<Map> getIntentData() async { static Future<Map<String, dynamic>> getIntentData() async {
try { try {
// returns nullable map with 'action' and possibly 'uri' 'mimeType' // 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) { } on PlatformException catch (e) {
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
class AIcons { class AIcons {

View file

@ -2,13 +2,14 @@ import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils { class AndroidFileUtils {
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath; late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
Set<StorageVolume> storageVolumes = {}; Set<StorageVolume> storageVolumes = {};
Set<Package> _packages = {}; Set<Package> _packages = {};
List<String> _potentialAppDirs = []; List<String> _potentialAppDirs = [];
@ -22,7 +23,7 @@ class AndroidFileUtils {
Future<void> init() async { Future<void> init() async {
storageVolumes = await storageService.getStorageVolumes(); storageVolumes = await storageService.getStorageVolumes();
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' // 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'); dcimPath = pContext.join(primaryStorage, 'DCIM');
downloadPath = pContext.join(primaryStorage, 'Download'); downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies'); moviesPath = pContext.join(primaryStorage, 'Movies');
@ -35,16 +36,17 @@ class AndroidFileUtils {
appNameChangeNotifier.notifyListeners(); 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; bool isDownloadPath(String path) => path == downloadPath;
StorageVolume getStorageVolume(String path) { StorageVolume? getStorageVolume(String? path) {
final volume = storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null); 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, // storage volume path includes trailing '/', but argument path may or may not,
// which is an issue when the path is at the root // which is an issue when the path is at the root
return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/'); return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/');
@ -53,7 +55,6 @@ class AndroidFileUtils {
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
AlbumType getAlbumType(String albumPath) { AlbumType getAlbumType(String albumPath) {
if (albumPath != null) {
if (isCameraPath(albumPath)) return AlbumType.camera; if (isCameraPath(albumPath)) return AlbumType.camera;
if (isDownloadPath(albumPath)) return AlbumType.download; if (isDownloadPath(albumPath)) return AlbumType.download;
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings; if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
@ -61,19 +62,18 @@ class AndroidFileUtils {
final dir = pContext.split(albumPath).last; final dir = pContext.split(albumPath).last;
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
}
return AlbumType.regular; return AlbumType.regular;
} }
String getAlbumAppPackageName(String albumPath) { String? getAlbumAppPackageName(String albumPath) {
if (albumPath == null) return null;
final dir = pContext.split(albumPath).last; 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; return package?.packageName;
} }
String getCurrentAppName(String packageName) { String? getCurrentAppName(String packageName) {
final package = _packages.firstWhere((package) => package.packageName == packageName, orElse: () => null); final package = _packages.firstWhereOrNull((package) => package.packageName == packageName);
return package?.currentLabel; return package?.currentLabel;
} }
} }
@ -81,25 +81,26 @@ class AndroidFileUtils {
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots } enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
class Package { class Package {
final String packageName, currentLabel, englishLabel; final String packageName;
final String? currentLabel, englishLabel;
final bool categoryLauncher, isSystem; final bool categoryLauncher, isSystem;
final Set<String> ownedDirs = {}; final Set<String> ownedDirs = {};
Package({ Package({
this.packageName, required this.packageName,
this.currentLabel, required this.currentLabel,
this.englishLabel, required this.englishLabel,
this.categoryLauncher, required this.categoryLauncher,
this.isSystem, required this.isSystem,
}); });
factory Package.fromMap(Map map) { factory Package.fromMap(Map map) {
return Package( return Package(
packageName: map['packageName'], packageName: map['packageName'] ?? '',
currentLabel: map['currentLabel'], currentLabel: map['currentLabel'],
englishLabel: map['englishLabel'], englishLabel: map['englishLabel'],
categoryLauncher: map['categoryLauncher'], categoryLauncher: map['categoryLauncher'] ?? false,
isSystem: map['isSystem'], isSystem: map['isSystem'] ?? false,
); );
} }
@ -107,7 +108,7 @@ class Package {
currentLabel, currentLabel,
englishLabel, englishLabel,
...ownedDirs, ...ownedDirs,
].where((dir) => dir != null).toSet(); ].where((dir) => dir != null).cast<String>().toSet();
@override @override
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}'; String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
@ -115,24 +116,25 @@ class Package {
@immutable @immutable
class StorageVolume { class StorageVolume {
final String _description, path, state; final String? _description;
final String path, state;
final bool isPrimary, isRemovable; final bool isPrimary, isRemovable;
const StorageVolume({ const StorageVolume({
String description, required String? description,
this.isPrimary, required this.isPrimary,
this.isRemovable, required this.isRemovable,
this.path, required this.path,
this.state, required this.state,
}) : _description = description; }) : _description = description;
String getDescription(BuildContext context) { String getDescription(BuildContext? context) {
if (_description != null) return _description; if (_description != null) return _description!;
// ideally, the context should always be provided, but in some cases (e.g. album comparison), // 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 // this would require numerous additional methods to have the context as argument
// for such a minor benefit: fallback volume description on Android < N // for such a minor benefit: fallback volume description on Android < N
if (isPrimary) return context?.l10n?.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage'; if (isPrimary) return context?.l10n.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage';
return context?.l10n?.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card'; return context?.l10n.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card';
} }
factory StorageVolume.fromMap(Map map) { factory StorageVolume.fromMap(Map map) {
@ -152,19 +154,19 @@ class VolumeRelativeDirectory {
final String volumePath, relativeDir; final String volumePath, relativeDir;
const VolumeRelativeDirectory({ const VolumeRelativeDirectory({
this.volumePath, required this.volumePath,
this.relativeDir, required this.relativeDir,
}); });
factory VolumeRelativeDirectory.fromMap(Map map) { static VolumeRelativeDirectory fromMap(Map map) {
return VolumeRelativeDirectory( return VolumeRelativeDirectory(
volumePath: map['volumePath'], volumePath: map['volumePath'] ?? '',
relativeDir: map['relativeDir'] ?? '', relativeDir: map['relativeDir'] ?? '',
); );
} }
// prefer static method over a null returning factory constructor // prefer static method over a null returning factory constructor
static VolumeRelativeDirectory fromPath(String dirPath) { static VolumeRelativeDirectory? fromPath(String dirPath) {
final volume = androidFileUtils.getStorageVolume(dirPath); final volume = androidFileUtils.getStorageVolume(dirPath);
if (volume == null) return null; if (volume == null) return null;
@ -177,7 +179,7 @@ class VolumeRelativeDirectory {
} }
String getVolumeDescription(BuildContext context) { 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; return volume?.getDescription(context) ?? volumePath;
} }

View file

@ -2,22 +2,22 @@ import 'package:flutter/foundation.dart';
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin // reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin
class AChangeNotifier implements Listenable { class AChangeNotifier implements Listenable {
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>(); ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
@override @override
void addListener(VoidCallback listener) => _listeners.add(listener); void addListener(VoidCallback listener) => _listeners!.add(listener);
@override @override
void removeListener(VoidCallback listener) => _listeners.remove(listener); void removeListener(VoidCallback listener) => _listeners!.remove(listener);
void dispose() => _listeners = null; void dispose() => _listeners = null;
void notifyListeners() { void notifyListeners() {
if (_listeners == null) return; if (_listeners == null) return;
final localListeners = List<VoidCallback>.from(_listeners); final localListeners = List<VoidCallback>.from(_listeners!);
for (final listener in localListeners) { for (final listener in localListeners) {
try { try {
if (_listeners.contains(listener)) listener(); if (_listeners!.contains(listener)) listener();
} catch (error, stack) { } catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack'); debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
} }

View file

@ -310,9 +310,9 @@ class Dependency {
final String licenseUrl; final String licenseUrl;
const Dependency({ const Dependency({
@required this.name, required this.name,
@required this.license, required this.license,
@required this.licenseUrl, required this.licenseUrl,
@required this.sourceUrl, required this.sourceUrl,
}); });
} }

View file

@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
class Debouncer { class Debouncer {
final Duration delay; 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?.cancel();
_timer = Timer(delay, action); _timer = Timer(delay, action);
} }

View file

@ -1,7 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
final double _log2 = log(2); final double _log2 = log(2);
const double _piOver180 = pi / 180.0; const double _piOver180 = pi / 180.0;
@ -9,12 +7,12 @@ double toDegrees(num radians) => radians / _piOver180;
double toRadians(num degrees) => degrees * _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 // e.g. x=12345, precision=3 should return 13000
int ceilBy(num x, int precision) { int ceilBy(num x, int precision) {
final factor = pow(10, precision); final factor = pow(10, precision);
return (x / factor).ceil() * factor; return (x / factor).ceil() * (factor as int);
} }

View file

@ -3,7 +3,7 @@ extension ExtraString on String {
static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])');
String toSentenceCase() { 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(); return s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(_sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim();
} }
} }

View file

@ -15,11 +15,11 @@ String formatPreciseDuration(Duration d) {
} }
extension ExtraDateTime on DateTime { extension ExtraDateTime on DateTime {
bool isAtSameYearAs(DateTime other) => this?.year == other?.year; bool isAtSameYearAs(DateTime? other) => year == other?.year;
bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && this?.month == other?.month; bool isAtSameMonthAs(DateTime? other) => isAtSameYearAs(other) && month == other?.month;
bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && this?.day == other?.day; bool isAtSameDayAs(DateTime? other) => isAtSameMonthAs(other) && day == other?.day;
bool get isToday => isAtSameDayAs(DateTime.now()); bool get isToday => isAtSameDayAs(DateTime.now());

View file

@ -13,7 +13,7 @@ class AppReference extends StatefulWidget {
} }
class _AppReferenceState extends State<AppReference> { class _AppReferenceState extends State<AppReference> {
Future<PackageInfo> _packageInfoLoader; late Future<PackageInfo> _packageInfoLoader;
@override @override
void initState() { void initState() {
@ -47,7 +47,7 @@ class _AppReferenceState extends State<AppReference> {
builder: (context, snapshot) { builder: (context, snapshot) {
return LinkChip( return LinkChip(
leading: AvesLogo( 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}', text: '${context.l10n.appName} ${snapshot.data?.version}',
url: 'https://github.com/deckerst/aves', url: 'https://github.com/deckerst/aves',
@ -59,7 +59,7 @@ class _AppReferenceState extends State<AppReference> {
Widget _buildFlutterLine() { Widget _buildFlutterLine() {
final style = DefaultTextStyle.of(context).style; final style = DefaultTextStyle.of(context).style;
final subColor = style.color.withOpacity(.6); final subColor = style.color!.withOpacity(.6);
return Text.rich( return Text.rich(
TextSpan( TextSpan(
@ -68,7 +68,7 @@ class _AppReferenceState extends State<AppReference> {
child: Padding( child: Padding(
padding: EdgeInsetsDirectional.only(end: 4), padding: EdgeInsetsDirectional.only(end: 4),
child: FlutterLogo( child: FlutterLogo(
size: style.fontSize * 1.25, size: style.fontSize! * 1.25,
), ),
), ),
), ),

View file

@ -12,8 +12,8 @@ class Licenses extends StatefulWidget {
} }
class _LicensesState extends State<Licenses> { class _LicensesState extends State<Licenses> {
final ValueNotifier<String> _expandedNotifier = ValueNotifier(null); final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
List<Dependency> _platform, _flutterPlugins, _flutterPackages, _dartPackages; late List<Dependency> _platform, _flutterPlugins, _flutterPackages, _dartPackages;
@override @override
void initState() { void initState() {
@ -118,8 +118,8 @@ class LicenseRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final bodyTextStyle = textTheme.bodyText2; final bodyTextStyle = textTheme.bodyText2!;
final subColor = bodyTextStyle.color.withOpacity(.6); final subColor = bodyTextStyle.color!.withOpacity(.6);
return Padding( return Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),

View file

@ -11,7 +11,7 @@ class AboutUpdate extends StatefulWidget {
} }
class _AboutUpdateState extends State<AboutUpdate> { class _AboutUpdateState extends State<AboutUpdate> {
Future<bool> _updateChecker; late Future<bool> _updateChecker;
@override @override
void initState() { void initState() {

171
lib/widgets/aves_app.dart Normal file
View 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);
}
});
}
}
}

View file

@ -35,9 +35,9 @@ class CollectionAppBar extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
const CollectionAppBar({ const CollectionAppBar({
Key key, Key? key,
@required this.appBarHeightNotifier, required this.appBarHeightNotifier,
@required this.collection, required this.collection,
}) : super(key: key); }) : super(key: key);
@override @override
@ -46,9 +46,9 @@ class CollectionAppBar extends StatefulWidget {
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin { class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController(); final TextEditingController _searchFieldController = TextEditingController();
EntrySetActionDelegate _actionDelegate; late EntrySetActionDelegate _actionDelegate;
AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
Future<bool> _canAddShortcutsLoader; late Future<bool> _canAddShortcutsLoader;
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@ -68,7 +68,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
_canAddShortcutsLoader = AppShortcutService.canPin(); _canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight()); WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
} }
@override @override
@ -127,8 +127,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
Widget _buildAppBarLeading() { Widget _buildAppBarLeading() {
VoidCallback onPressed; VoidCallback? onPressed;
String tooltip; String? tooltip;
if (collection.isBrowsing) { if (collection.isBrowsing) {
onPressed = Scaffold.of(context).openDrawer; onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
@ -147,7 +147,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget _buildAppBarTitle() { Widget? _buildAppBarTitle() {
if (collection.isBrowsing) { if (collection.isBrowsing) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); 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(); final sortedFilters = List<CollectionFilter>.from(filters)..sort();
defaultName = sortedFilters.first.getLabel(context); defaultName = sortedFilters.first.getLabel(context);
} }
final result = await showDialog<Tuple2<AvesEntry, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
builder: (context) => AddShortcutDialog( builder: (context) => AddShortcutDialog(
collection: collection, collection: collection,
defaultName: defaultName, defaultName: defaultName ?? '',
), ),
); );
if (result == null) return;
final coverEntry = result.item1; final coverEntry = result.item1;
final name = result.item2; final name = result.item2;
if (name.isEmpty) return;
if (name == null || name.isEmpty) return;
unawaited(AppShortcutService.pin(name, coverEntry, filters)); unawaited(AppShortcutService.pin(name, coverEntry, filters));
} }

View file

@ -35,7 +35,7 @@ import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class CollectionGrid extends StatefulWidget { class CollectionGrid extends StatefulWidget {
final String settingsRouteKey; final String? settingsRouteKey;
const CollectionGrid({ const CollectionGrid({
this.settingsRouteKey, this.settingsRouteKey,
@ -46,18 +46,18 @@ class CollectionGrid extends StatefulWidget {
} }
class _CollectionGridState extends State<CollectionGrid> { class _CollectionGridState extends State<CollectionGrid> {
TileExtentController _tileExtentController; TileExtentController? _tileExtentController;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController( _tileExtentController ??= TileExtentController(
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName, settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
columnCountDefault: 4, columnCountDefault: 4,
extentMin: 46, extentMin: 46,
spacing: 0, spacing: 0,
); );
return TileExtentControllerProvider( return TileExtentControllerProvider(
controller: _tileExtentController, controller: _tileExtentController!,
child: _CollectionGridContent(), child: _CollectionGridContent(),
); );
} }
@ -99,7 +99,7 @@ class _CollectionGridContent extends StatelessWidget {
child: _CollectionSectionedContent( child: _CollectionSectionedContent(
collection: collection, collection: collection,
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context), scrollController: PrimaryScrollController.of(context)!,
), ),
); );
}, },
@ -119,9 +119,9 @@ class _CollectionSectionedContent extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
const _CollectionSectionedContent({ const _CollectionSectionedContent({
@required this.collection, required this.collection,
@required this.isScrollingNotifier, required this.isScrollingNotifier,
@required this.scrollController, required this.scrollController,
}); });
@override @override
@ -177,9 +177,9 @@ class _CollectionScaler extends StatelessWidget {
final Widget child; final Widget child;
const _CollectionScaler({ const _CollectionScaler({
@required this.scrollableKey, required this.scrollableKey,
@required this.appBarHeightNotifier, required this.appBarHeightNotifier,
@required this.child, required this.child,
}); });
@override @override
@ -228,12 +228,12 @@ class _CollectionScrollView extends StatefulWidget {
final ScrollController scrollController; final ScrollController scrollController;
const _CollectionScrollView({ const _CollectionScrollView({
@required this.scrollableKey, required this.scrollableKey,
@required this.collection, required this.collection,
@required this.appBar, required this.appBar,
@required this.appBarHeightNotifier, required this.appBarHeightNotifier,
@required this.isScrollingNotifier, required this.isScrollingNotifier,
@required this.scrollController, required this.scrollController,
}); });
@override @override
@ -241,7 +241,7 @@ class _CollectionScrollView extends StatefulWidget {
} }
class _CollectionScrollViewState extends State<_CollectionScrollView> { class _CollectionScrollViewState extends State<_CollectionScrollView> {
Timer _scrollMonitoringTimer; Timer? _scrollMonitoringTimer;
@override @override
void initState() { void initState() {

View file

@ -13,8 +13,8 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
final double offsetY; final double offsetY;
const CollectionDraggableThumbLabel({ const CollectionDraggableThumbLabel({
@required this.collection, required this.collection,
@required this.offsetY, required this.offsetY,
}); });
@override @override
@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
case EntryGroupFactor.album: case EntryGroupFactor.album:
return [ return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), 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.month:
case EntryGroupFactor.none: case EntryGroupFactor.none:
@ -40,21 +40,23 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
DraggableThumbLabel.formatDayThumbLabel(context, entry.bestDate), DraggableThumbLabel.formatDayThumbLabel(context, entry.bestDate),
]; ];
} }
break;
case EntrySortFactor.name: case EntrySortFactor.name:
return [ return [
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory), if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
entry.bestTitle, if (entry.bestTitle != null) entry.bestTitle!,
]; ];
case EntrySortFactor.size: case EntrySortFactor.size:
return [ 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 _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!);
} }

View file

@ -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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -28,7 +27,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
Set<AvesEntry> get selection => collection.selection; Set<AvesEntry> get selection => collection.selection;
EntrySetActionDelegate({ EntrySetActionDelegate({
@required this.collection, required this.collection,
}); });
void onEntryActionSelected(BuildContext context, EntryAction action) { 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 { Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) 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();
if (moveType == MoveType.move) { if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions, // check whether moving is possible given OS restrictions,
// before asking to pick a destination album // before asking to pick a destination album
@ -134,7 +133,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
Future<void> _showDeleteDialog(BuildContext context) async { 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 todoCount = selection.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(

View file

@ -9,12 +9,12 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
final List<CollectionFilter> filters; final List<CollectionFilter> filters;
final bool removable; final bool removable;
final FilterCallback onTap; final FilterCallback? onTap;
FilterBar({ FilterBar({
Key key, Key? key,
@required Set<CollectionFilter> filters, required Set<CollectionFilter> filters,
@required this.removable, required this.removable,
this.onTap, this.onTap,
}) : filters = List<CollectionFilter>.from(filters)..sort(), }) : filters = List<CollectionFilter>.from(filters)..sort(),
super(key: key); super(key: key);
@ -28,9 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
class _FilterBarState extends State<FilterBar> { class _FilterBarState extends State<FilterBar> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list'); 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 @override
void didUpdateWidget(covariant FilterBar oldWidget) { 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, // only animate item removal when triggered by a user interaction with the chip,
// not from automatic chip replacement following chip selection // not from automatic chip replacement following chip selection
final animate = _userTappedFilter == filter; final animate = _userTappedFilter == filter;
listState.removeItem( listState!.removeItem(
index, index,
animate animate
? (context, animation) { ? (context, animation) {
@ -69,7 +69,7 @@ class _FilterBarState extends State<FilterBar> {
}); });
added.forEach((filter) { added.forEach((filter) {
final index = current.indexOf(filter); final index = current.indexOf(filter);
listState.insertItem( listState!.insertItem(
index, index,
duration: Duration.zero, duration: Duration.zero,
); );
@ -95,7 +95,7 @@ class _FilterBarState extends State<FilterBar> {
physics: BouncingScrollPhysics(), physics: BouncingScrollPhysics(),
padding: EdgeInsets.only(left: 8), padding: EdgeInsets.only(left: 8),
itemBuilder: (context, index, animation) { itemBuilder: (context, index, animation) {
if (index >= widget.filters.length) return null; if (index >= widget.filters.length) return SizedBox();
return _buildChip(widget.filters.toList()[index]); return _buildChip(widget.filters.toList()[index]);
}, },
), ),
@ -115,7 +115,7 @@ class _FilterBarState extends State<FilterBar> {
onTap: onTap != null onTap: onTap != null
? (filter) { ? (filter) {
_userTappedFilter = filter; _userTappedFilter = filter;
onTap(filter); onTap!(filter);
} }
: null, : null,
), ),

View file

@ -2,22 +2,25 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.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/grid/header.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AlbumSectionHeader extends StatelessWidget { class AlbumSectionHeader extends StatelessWidget {
final String directory, albumName; final String? directory, albumName;
const AlbumSectionHeader({ const AlbumSectionHeader({
Key key, Key? key,
@required this.directory, required this.directory,
@required this.albumName, required this.albumName,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { 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) { if (albumIcon != null) {
albumIcon = Material( albumIcon = Material(
type: MaterialType.circle, type: MaterialType.circle,
@ -27,11 +30,12 @@ class AlbumSectionHeader extends StatelessWidget {
child: albumIcon, child: albumIcon,
); );
} }
}
return SectionHeader( return SectionHeader(
sectionKey: EntryAlbumSectionKey(directory), sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon, leading: albumIcon,
title: albumName, title: albumName ?? context.l10n.sectionUnknown,
trailing: androidFileUtils.isOnRemovableStorage(directory) trailing: directory != null && androidFileUtils.isOnRemovableStorage(directory!)
? Icon( ? Icon(
AIcons.removableStorage, AIcons.removableStorage,
size: 16, size: 16,
@ -42,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget {
} }
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) { 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( return SectionHeader.getPreferredHeight(
context: context, context: context,
maxWidth: maxWidth, maxWidth: maxWidth,

View file

@ -15,10 +15,10 @@ class CollectionSectionHeader extends StatelessWidget {
final double height; final double height;
const CollectionSectionHeader({ const CollectionSectionHeader({
Key key, Key? key,
@required this.collection, required this.collection,
@required this.sectionKey, required this.sectionKey,
@required this.height, required this.height,
}) : super(key: key); }) : super(key: key);
@override @override
@ -32,7 +32,7 @@ class CollectionSectionHeader extends StatelessWidget {
: SizedBox.shrink(); : SizedBox.shrink();
} }
Widget _buildHeader(BuildContext context) { Widget? _buildHeader(BuildContext context) {
switch (collection.sortFactor) { switch (collection.sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (collection.groupFactor) { switch (collection.groupFactor) {
@ -60,7 +60,7 @@ class CollectionSectionHeader extends StatelessWidget {
return AlbumSectionHeader( return AlbumSectionHeader(
key: ValueKey(sectionKey), key: ValueKey(sectionKey),
directory: directory, directory: directory,
albumName: source.getAlbumDisplayName(context, directory), albumName: directory != null ? source.getAlbumDisplayName(context, directory) : null,
); );
} }

View file

@ -6,11 +6,11 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DaySectionHeader extends StatelessWidget { class DaySectionHeader extends StatelessWidget {
final DateTime date; final DateTime? date;
const DaySectionHeader({ const DaySectionHeader({
Key key, Key? key,
@required this.date, required this.date,
}) : super(key: key); }) : super(key: key);
// Examples (en_US): // Examples (en_US):
@ -33,7 +33,7 @@ class DaySectionHeader extends StatelessWidget {
// `MEd`: `1. 26. ()` // `MEd`: `1. 26. ()`
// `yMEd`: `2021. 1. 26. ()` // `yMEd`: `2021. 1. 26. ()`
static String _formatDate(BuildContext context, DateTime date) { static String _formatDate(BuildContext context, DateTime? date) {
final l10n = context.l10n; final l10n = context.l10n;
if (date == null) return l10n.sectionUnknown; if (date == null) return l10n.sectionUnknown;
if (date.isToday) return l10n.dateToday; if (date.isToday) return l10n.dateToday;
@ -53,14 +53,14 @@ class DaySectionHeader extends StatelessWidget {
} }
class MonthSectionHeader extends StatelessWidget { class MonthSectionHeader extends StatelessWidget {
final DateTime date; final DateTime? date;
const MonthSectionHeader({ const MonthSectionHeader({
Key key, Key? key,
@required this.date, required this.date,
}) : super(key: key); }) : super(key: key);
static String _formatDate(BuildContext context, DateTime date) { static String _formatDate(BuildContext context, DateTime? date) {
final l10n = context.l10n; final l10n = context.l10n;
if (date == null) return l10n.sectionUnknown; if (date == null) return l10n.sectionUnknown;
if (date.isThisMonth) return l10n.dateThisMonth; if (date.isThisMonth) return l10n.dateThisMonth;

View file

@ -3,20 +3,19 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/collection/grid/headers/any.dart'; import 'package:aves/widgets/collection/grid/headers/any.dart';
import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> { class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
final CollectionLens collection; final CollectionLens collection;
const SectionedEntryListLayoutProvider({ const SectionedEntryListLayoutProvider({
@required this.collection, required this.collection,
@required double scrollableWidth, required double scrollableWidth,
@required int columnCount, required int columnCount,
@required double tileExtent, required double tileExtent,
@required Widget Function(AvesEntry entry) tileBuilder, required Widget Function(AvesEntry entry) tileBuilder,
@required Duration tileAnimationDelay, required Duration tileAnimationDelay,
@required Widget child, required Widget child,
}) : super( }) : super(
scrollableWidth: scrollableWidth, scrollableWidth: scrollableWidth,
columnCount: columnCount, columnCount: columnCount,

View file

@ -19,10 +19,10 @@ class GridSelectionGestureDetector extends StatefulWidget {
const GridSelectionGestureDetector({ const GridSelectionGestureDetector({
this.selectable = true, this.selectable = true,
@required this.collection, required this.collection,
@required this.scrollController, required this.scrollController,
@required this.appBarHeightNotifier, required this.appBarHeightNotifier,
@required this.child, required this.child,
}); });
@override @override
@ -30,16 +30,16 @@ class GridSelectionGestureDetector extends StatefulWidget {
} }
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> { class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
bool _pressing = false, _selecting; bool _pressing = false, _selecting = false;
int _fromIndex, _lastToIndex; late int _fromIndex, _lastToIndex;
Offset _localPosition; late Offset _localPosition;
EdgeInsets _scrollableInsets; late EdgeInsets _scrollableInsets;
double _scrollSpeedFactor; late double _scrollSpeedFactor;
Timer _updateTimer; Timer? _updateTimer;
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
List<AvesEntry> get entries => collection.sortedEntries; List<AvesEntry > get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
@ -102,8 +102,10 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
} }
final toEntry = _getEntryAt(_localPosition); final toEntry = _getEntryAt(_localPosition);
if (toEntry != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry)); _toggleSelectionToIndex(entries.indexOf(toEntry));
} }
}
void _setScrollSpeed(double speedFactor) { void _setScrollSpeed(double speedFactor) {
if (speedFactor == _scrollSpeedFactor) return; 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, // 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, // 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. // so we use custom layout computation instead to find the entry.

View file

@ -13,13 +13,13 @@ class InteractiveThumbnail extends StatelessWidget {
final CollectionLens collection; final CollectionLens collection;
final AvesEntry entry; final AvesEntry entry;
final double tileExtent; final double tileExtent;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool>? isScrollingNotifier;
const InteractiveThumbnail({ const InteractiveThumbnail({
Key key, Key? key,
this.collection, required this.collection,
@required this.entry, required this.entry,
@required this.tileExtent, required this.tileExtent,
this.isScrollingNotifier, this.isScrollingNotifier,
}) : super(key: key); }) : super(key: key);

View file

@ -8,17 +8,17 @@ import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget { class DecoratedThumbnail extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final CollectionLens collection; final CollectionLens? collection;
final ValueNotifier<bool> cancellableNotifier; final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable; final bool selectable, highlightable;
static final Color borderColor = Colors.grey.shade700; static final Color borderColor = Colors.grey.shade700;
static const double borderWidth = .5; static const double borderWidth = .5;
const DecoratedThumbnail({ const DecoratedThumbnail({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
this.collection, this.collection,
this.cancellableNotifier, this.cancellableNotifier,
this.selectable = true, this.selectable = true,

View file

@ -13,9 +13,9 @@ class ErrorThumbnail extends StatefulWidget {
final String tooltip; final String tooltip;
const ErrorThumbnail({ const ErrorThumbnail({
@required this.entry, required this.entry,
@required this.extent, required this.extent,
@required this.tooltip, required this.tooltip,
}); });
@override @override
@ -23,7 +23,7 @@ class ErrorThumbnail extends StatefulWidget {
} }
class _ErrorThumbnailState extends State<ErrorThumbnail> { class _ErrorThumbnailState extends State<ErrorThumbnail> {
Future<bool> _exists; late Future<bool> _exists;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -32,7 +32,7 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_exists = entry.path != null ? File(entry.path).exists() : SynchronousFuture(true); _exists = entry.path != null ? File(entry.path!).exists() : SynchronousFuture(true);
} }
@override @override
@ -42,7 +42,7 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
future: _exists, future: _exists,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return SizedBox(); if (snapshot.connectionState != ConnectionState.done) return SizedBox();
final exists = snapshot.data; final exists = snapshot.data!;
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
color: Colors.black, color: Colors.black,

View file

@ -17,9 +17,9 @@ class ThumbnailEntryOverlay extends StatelessWidget {
final double extent; final double extent;
const ThumbnailEntryOverlay({ const ThumbnailEntryOverlay({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
}) : super(key: key); }) : super(key: key);
@override @override
@ -56,9 +56,9 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
static const duration = Durations.thumbnailOverlayAnimation; static const duration = Durations.thumbnailOverlayAnimation;
const ThumbnailSelectionOverlay({ const ThumbnailSelectionOverlay({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
}) : super(key: key); }) : super(key: key);
@override @override
@ -113,9 +113,9 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
final double extent; final double extent;
const ThumbnailHighlightOverlay({ const ThumbnailHighlightOverlay({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
}) : super(key: key); }) : super(key: key);
@override @override

View file

@ -10,13 +10,13 @@ import 'package:flutter/material.dart';
class RasterImageThumbnail extends StatefulWidget { class RasterImageThumbnail extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final ValueNotifier<bool> cancellableNotifier; final ValueNotifier<bool>? cancellableNotifier;
final Object heroTag; final Object? heroTag;
const RasterImageThumbnail({ const RasterImageThumbnail({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
this.cancellableNotifier, this.cancellableNotifier,
this.heroTag, this.heroTag,
}) : super(key: key); }) : super(key: key);
@ -26,7 +26,7 @@ class RasterImageThumbnail extends StatefulWidget {
} }
class _RasterImageThumbnailState extends State<RasterImageThumbnail> { class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider; ThumbnailProvider? _fastThumbnailProvider, _sizedThumbnailProvider;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -85,7 +85,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
final fastImage = Image( final fastImage = Image(
key: ValueKey('LQ'), key: ValueKey('LQ'),
image: _fastThumbnailProvider, image: _fastThumbnailProvider!,
errorBuilder: _buildError, errorBuilder: _buildError,
width: extent, width: extent,
height: extent, height: extent,
@ -95,7 +95,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
? fastImage ? fastImage
: Image( : Image(
key: ValueKey('HQ'), key: ValueKey('HQ'),
image: _sizedThumbnailProvider, image: _sizedThumbnailProvider!,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child; if (wasSynchronouslyLoaded) return child;
return AnimatedSwitcher( return AnimatedSwitcher(
@ -123,7 +123,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
); );
return widget.heroTag != null return widget.heroTag != null
? Hero( ? Hero(
tag: widget.heroTag, tag: widget.heroTag!,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
return TransitionImage( return TransitionImage(
image: entry.getBestThumbnail(extent), image: entry.getBestThumbnail(extent),
@ -136,7 +136,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
: image; : image;
} }
Widget _buildError(BuildContext context, Object error, StackTrace stackTrace) => ErrorThumbnail( Widget _buildError(BuildContext context, Object error, StackTrace? stackTrace) => ErrorThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
tooltip: error.toString(), tooltip: error.toString(),

View file

@ -6,13 +6,13 @@ import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget { class ThumbnailTheme extends StatelessWidget {
final double extent; final double extent;
final bool showLocation; final bool? showLocation;
final Widget child; final Widget child;
const ThumbnailTheme({ const ThumbnailTheme({
@required this.extent, required this.extent,
this.showLocation, this.showLocation,
@required this.child, required this.child,
}); });
@override @override
@ -39,10 +39,10 @@ class ThumbnailThemeData {
final bool showLocation, showRaw, showVideoDuration; final bool showLocation, showRaw, showVideoDuration;
const ThumbnailThemeData({ const ThumbnailThemeData({
@required this.iconSize, required this.iconSize,
@required this.fontSize, required this.fontSize,
@required this.showLocation, required this.showLocation,
@required this.showRaw, required this.showRaw,
@required this.showVideoDuration, required this.showVideoDuration,
}); });
} }

View file

@ -11,12 +11,12 @@ import 'package:provider/provider.dart';
class VectorImageThumbnail extends StatelessWidget { class VectorImageThumbnail extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final Object heroTag; final Object? heroTag;
const VectorImageThumbnail({ const VectorImageThumbnail({
Key key, Key? key,
@required this.entry, required this.entry,
@required this.extent, required this.extent,
this.heroTag, this.heroTag,
}) : super(key: key); }) : super(key: key);
@ -31,7 +31,7 @@ class VectorImageThumbnail extends StatelessWidget {
builder: (context, constraints) { builder: (context, constraints) {
final availableSize = constraints.biggest; final availableSize = constraints.biggest;
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination; 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( final child = CustomPaint(
painter: CheckeredPainter(checkSize: extent / 8, offset: offset), painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
child: SvgPicture( child: SvgPicture(
@ -66,7 +66,7 @@ class VectorImageThumbnail extends StatelessWidget {
); );
return heroTag != null return heroTag != null
? Hero( ? Hero(
tag: heroTag, tag: heroTag!,
transitionOnUserGestures: true, transitionOnUserGestures: true,
child: child, child: child,
) )

View file

@ -21,12 +21,12 @@ mixin FeedbackMixin {
// report overlay for multiple operations // report overlay for multiple operations
void showOpReport<T>({ void showOpReport<T>({
@required BuildContext context, required BuildContext context,
@required Stream<T> opStream, required Stream<T> opStream,
@required int itemCount, required int itemCount,
void Function(Set<T> processed) onDone, void Function(Set<T> processed)? onDone,
}) { }) {
OverlayEntry _opReportOverlayEntry; late OverlayEntry _opReportOverlayEntry;
_opReportOverlayEntry = OverlayEntry( _opReportOverlayEntry = OverlayEntry(
builder: (context) => ReportOverlay<T>( builder: (context) => ReportOverlay<T>(
opStream: opStream, 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; final void Function(Set<T> processed) onDone;
const ReportOverlay({ const ReportOverlay({
@required this.opStream, required this.opStream,
@required this.itemCount, required this.itemCount,
@required this.onDone, required this.onDone,
}); });
@override @override
@ -58,8 +58,8 @@ class ReportOverlay<T> extends StatefulWidget {
class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin { class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin {
final processed = <T>{}; final processed = <T>{};
AnimationController _animationController; late AnimationController _animationController;
Animation<double> _animation; late Animation<double> _animation;
Stream<T> get opStream => widget.opStream; Stream<T> get opStream => widget.opStream;

View file

@ -3,21 +3,21 @@ import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
mixin PermissionAwareMixin { mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) { 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 { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final restrictedDirs = await storageService.getRestrictedDirectories(); final restrictedDirs = await storageService.getRestrictedDirectories();
while (true) { while (true) {
final dirs = await storageService.getInaccessibleDirectories(albumPaths); final dirs = await storageService.getInaccessibleDirectories(albumPaths);
if (dirs == null) return false;
if (dirs.isEmpty) return true; if (dirs.isEmpty) return true;
final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null); final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains);
if (restrictedInaccessibleDir != null) { if (restrictedInaccessibleDir != null) {
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir); await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
return false; 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>( return showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {

View file

@ -15,14 +15,19 @@ import 'package:flutter/widgets.dart';
mixin SizeAwareMixin { mixin SizeAwareMixin {
Future<bool> checkFreeSpaceForMove( Future<bool> checkFreeSpaceForMove(
BuildContext context, BuildContext context,
Set<AvesEntry> selection, Set<AvesEntry > selection,
String destinationAlbum, String destinationAlbum,
MoveType moveType, MoveType moveType,
) async { ) async {
// assume we have enough space if we cannot find the volume or its remaining free space
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
if (destinationVolume == null) return true;
final free = await storageService.getFreeSpace(destinationVolume); final free = await storageService.getFreeSpace(destinationVolume);
int needed; if (free == null) return true;
int sumSize(sum, entry) => sum + entry.sizeBytes;
late int needed;
int sumSize(sum, entry) => sum + entry.sizeBytes ?? 0;
switch (moveType) { switch (moveType) {
case MoveType.copy: case MoveType.copy:
case MoveType.export: case MoveType.export:
@ -30,11 +35,11 @@ mixin SizeAwareMixin {
break; break;
case MoveType.move: case MoveType.move:
// when moving, we only need space for the entries that are not already on the destination volume // 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 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 // 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); needed = max(fromOtherVolumes, largestSingle);
break; break;
} }

View file

@ -9,9 +9,9 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
const SourceStateAwareAppBarTitle({ const SourceStateAwareAppBarTitle({
Key key, Key? key,
@required this.title, required this.title,
@required this.source, required this.source,
}) : super(key: key); }) : super(key: key);
@override @override
@ -49,11 +49,11 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
class SourceStateSubtitle extends StatelessWidget { class SourceStateSubtitle extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
const SourceStateSubtitle({@required this.source}); const SourceStateSubtitle({required this.source});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String subtitle; String? subtitle;
switch (source.stateNotifier.value) { switch (source.stateNotifier.value) {
case SourceState.loading: case SourceState.loading:
subtitle = context.l10n.sourceStateLoading; subtitle = context.l10n.sourceStateLoading;
@ -79,12 +79,12 @@ class SourceStateSubtitle extends StatelessWidget {
stream: source.progressStream, stream: source.progressStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink(); if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink();
final progress = snapshot.data; final progress = snapshot.data!;
return Padding( return Padding(
padding: EdgeInsetsDirectional.only(start: 8), padding: EdgeInsetsDirectional.only(start: 8),
child: Text( child: Text(
'${progress.done}/${progress.total}', '${progress.done}/${progress.total}',
style: subtitleStyle.copyWith(color: Colors.white30), style: subtitleStyle!.copyWith(color: Colors.white30),
), ),
); );
}, },

View file

@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class InteractiveAppBarTitle extends StatelessWidget { class InteractiveAppBarTitle extends StatelessWidget {
final GestureTapCallback onTap; final GestureTapCallback? onTap;
final Widget child; final Widget child;
const InteractiveAppBarTitle({ const InteractiveAppBarTitle({
this.onTap, this.onTap,
@required this.child, required this.child,
}); });
@override @override

View file

@ -14,7 +14,7 @@ class AvesHighlightView extends StatelessWidget {
/// It is recommended to give it a value for performance /// It is recommended to give it a value for performance
/// ///
/// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages) /// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages)
final String language; final String? language;
/// Highlight theme /// Highlight theme
/// ///
@ -22,12 +22,12 @@ class AvesHighlightView extends StatelessWidget {
final Map<String, TextStyle> theme; final Map<String, TextStyle> theme;
/// Padding /// Padding
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry? padding;
/// Text styles /// Text styles
/// ///
/// Specify text styles such as font family and font size /// Specify text styles such as font family and font size
final TextStyle textStyle; final TextStyle? textStyle;
AvesHighlightView( AvesHighlightView(
String input, { String input, {
@ -45,16 +45,16 @@ class AvesHighlightView extends StatelessWidget {
void _traverse(Node node) { void _traverse(Node node) {
if (node.value != null) { 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) { } else if (node.children != null) {
final tmp = <TextSpan>[]; final tmp = <TextSpan>[];
currentSpans.add(TextSpan(children: tmp, style: theme[node.className])); currentSpans.add(TextSpan(children: tmp, style: theme[node.className!]));
stack.add(currentSpans); stack.add(currentSpans);
currentSpans = tmp; currentSpans = tmp;
node.children.forEach((n) { node.children!.forEach((n) {
_traverse(n); _traverse(n);
if (n == node.children.last) { if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast(); currentSpans = stack.isEmpty ? spans : stack.removeLast();
} }
}); });
@ -93,7 +93,7 @@ class AvesHighlightView extends StatelessWidget {
child: SelectableText.rich( child: SelectableText.rich(
TextSpan( TextSpan(
style: _textStyle, style: _textStyle,
children: _convert(highlight.parse(source, language: language).nodes), children: _convert(highlight.parse(source, language: language).nodes!),
), ),
), ),
); );

View file

@ -18,7 +18,7 @@ typedef ScrollThumbBuilder = Widget Function(
Animation<double> thumbAnimation, Animation<double> thumbAnimation,
Animation<double> labelAnimation, Animation<double> labelAnimation,
double height, { double height, {
Widget labelText, Widget? labelText,
}); });
/// Build a Text widget using the current scroll offset /// Build a Text widget using the current scroll offset
@ -37,7 +37,7 @@ class DraggableScrollbar extends StatefulWidget {
final ScrollThumbBuilder scrollThumbBuilder; final ScrollThumbBuilder scrollThumbBuilder;
/// The amount of padding that should surround the thumb /// 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 /// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration; final Duration scrollbarAnimationDuration;
@ -46,7 +46,7 @@ class DraggableScrollbar extends StatefulWidget {
final Duration scrollbarTimeToFade; final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView /// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder labelTextBuilder; final LabelTextBuilder? labelTextBuilder;
/// The ScrollController for the BoxScrollView /// The ScrollController for the BoxScrollView
final ScrollController controller; final ScrollController controller;
@ -55,30 +55,28 @@ class DraggableScrollbar extends StatefulWidget {
final ScrollView child; final ScrollView child;
DraggableScrollbar({ DraggableScrollbar({
Key key, Key? key,
@required this.backgroundColor, required this.backgroundColor,
@required this.scrollThumbHeight, required this.scrollThumbHeight,
@required this.scrollThumbBuilder, required this.scrollThumbBuilder,
@required this.controller, required this.controller,
this.padding, this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 1000), this.scrollbarTimeToFade = const Duration(milliseconds: 1000),
this.labelTextBuilder, this.labelTextBuilder,
@required this.child, required this.child,
}) : assert(controller != null), }) : assert(child.scrollDirection == Axis.vertical),
assert(scrollThumbBuilder != null),
assert(child.scrollDirection == Axis.vertical),
super(key: key); super(key: key);
@override @override
_DraggableScrollbarState createState() => _DraggableScrollbarState(); _DraggableScrollbarState createState() => _DraggableScrollbarState();
static Widget buildScrollThumbAndLabel({ static Widget buildScrollThumbAndLabel({
@required Widget scrollThumb, required Widget scrollThumb,
@required Color backgroundColor, required Color backgroundColor,
@required Animation<double> thumbAnimation, required Animation<double> thumbAnimation,
@required Animation<double> labelAnimation, required Animation<double> labelAnimation,
@required Widget labelText, required Widget? labelText,
}) { }) {
final scrollThumbAndLabel = labelText == null final scrollThumbAndLabel = labelText == null
? scrollThumb ? scrollThumb
@ -108,10 +106,10 @@ class ScrollLabel extends StatelessWidget {
final Widget child; final Widget child;
const ScrollLabel({ const ScrollLabel({
Key key, Key? key,
@required this.child, required this.child,
@required this.animation, required this.animation,
@required this.backgroundColor, required this.backgroundColor,
}) : super(key: key); }) : super(key: key);
@override @override
@ -134,13 +132,13 @@ class ScrollLabel extends StatelessWidget {
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin { class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0); final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0);
bool _isDragInProcess = false; bool _isDragInProcess = false;
Offset _longPressLastGlobalPosition; late Offset _longPressLastGlobalPosition;
AnimationController _thumbAnimationController; late AnimationController _thumbAnimationController;
Animation<double> _thumbAnimation; late Animation<double> _thumbAnimation;
AnimationController _labelAnimationController; late AnimationController _labelAnimationController;
Animation<double> _labelAnimation; late Animation<double> _labelAnimation;
Timer _fadeoutTimer; Timer? _fadeoutTimer;
@override @override
void initState() { void initState() {
@ -177,7 +175,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
ScrollController get controller => widget.controller; 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; double get thumbMinScrollExtent => 0.0;
@ -208,20 +206,20 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
onVerticalDragStart: (_) => _onVerticalDragStart(), onVerticalDragStart: (_) => _onVerticalDragStart(),
onVerticalDragUpdate: (details) => _onVerticalDragUpdate(details.delta.dy), onVerticalDragUpdate: (details) => _onVerticalDragUpdate(details.delta.dy),
onVerticalDragEnd: (_) => _onVerticalDragEnd(), onVerticalDragEnd: (_) => _onVerticalDragEnd(),
child: ValueListenableBuilder( child: ValueListenableBuilder<double>(
valueListenable: _thumbOffsetNotifier, valueListenable: _thumbOffsetNotifier,
builder: (context, thumbOffset, child) => Container( builder: (context, thumbOffset, child) => Container(
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
padding: EdgeInsets.only(top: thumbOffset) + widget.padding, padding: EdgeInsets.only(top: thumbOffset) + (widget.padding ?? EdgeInsets.zero),
child: widget.scrollThumbBuilder( child: widget.scrollThumbBuilder(
widget.backgroundColor, widget.backgroundColor,
_thumbAnimation, _thumbAnimation,
_labelAnimation, _labelAnimation,
widget.scrollThumbHeight, widget.scrollThumbHeight,
labelText: (widget.labelTextBuilder != null && _isDragInProcess) labelText: (widget.labelTextBuilder != null && _isDragInProcess)
? ValueListenableBuilder( ? ValueListenableBuilder<double>(
valueListenable: _viewOffsetNotifier, valueListenable: _viewOffsetNotifier,
builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset), builder: (context, viewOffset, child) => widget.labelTextBuilder!.call(viewOffset + thumbOffset),
) )
: null, : null,
), ),
@ -376,16 +374,16 @@ class SlideFadeTransition extends StatelessWidget {
final Widget child; final Widget child;
const SlideFadeTransition({ const SlideFadeTransition({
Key key, Key? key,
@required this.animation, required this.animation,
@required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, child) => animation.value == 0.0 ? Container() : child, builder: (context, child) => animation.value == 0.0 ? Container() : child!,
child: SlideTransition( child: SlideTransition(
position: Tween( position: Tween(
begin: Offset(0.3, 0.0), begin: Offset(0.3, 0.0),

View file

@ -27,7 +27,7 @@ class BottomGestureAreaProtector extends StatelessWidget {
class GestureAreaProtectorStack extends StatelessWidget { class GestureAreaProtectorStack extends StatelessWidget {
final Widget child; final Widget child;
const GestureAreaProtectorStack({@required this.child}); const GestureAreaProtectorStack({required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
class LabeledCheckbox extends StatefulWidget { class LabeledCheckbox extends StatefulWidget {
final bool value; final bool value;
final ValueChanged<bool> onChanged; final ValueChanged<bool?> onChanged;
final String text; final String text;
const LabeledCheckbox({ const LabeledCheckbox({
Key key, Key? key,
@required this.value, required this.value,
@required this.onChanged, required this.onChanged,
@required this.text, required this.text,
}) : super(key: key); }) : super(key: key);
@override @override
@ -18,7 +18,7 @@ class LabeledCheckbox extends StatefulWidget {
} }
class _LabeledCheckboxState extends State<LabeledCheckbox> { class _LabeledCheckboxState extends State<LabeledCheckbox> {
TapGestureRecognizer _tapRecognizer; late TapGestureRecognizer _tapRecognizer;
@override @override
void initState() { void initState() {

View file

@ -3,19 +3,19 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class LinkChip extends StatelessWidget { class LinkChip extends StatelessWidget {
final Widget leading; final Widget? leading;
final String text; final String text;
final String url; final String url;
final Color color; final Color? color;
final TextStyle textStyle; final TextStyle? textStyle;
static final borderRadius = BorderRadius.circular(8); static final borderRadius = BorderRadius.circular(8);
const LinkChip({ const LinkChip({
Key key, Key? key,
this.leading, this.leading,
@required this.text, required this.text,
@required this.url, required this.url,
this.color, this.color,
this.textStyle, this.textStyle,
}) : super(key: key); }) : super(key: key);
@ -37,7 +37,7 @@ class LinkChip extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (leading != null) ...[ if (leading != null) ...[
leading, leading!,
SizedBox(width: 8), SizedBox(width: 8),
], ],
Flexible( Flexible(

View file

@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
class MenuRow extends StatelessWidget { class MenuRow extends StatelessWidget {
final String text; final String text;
final IconData icon; final IconData? icon;
final bool checked; final bool? checked;
const MenuRow({ const MenuRow({
Key key, Key? key,
this.text, required this.text,
this.icon, this.icon,
this.checked, this.checked,
}) : super(key: key); }) : super(key: key);
@ -16,12 +16,12 @@ class MenuRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = IconTheme.of(context).size * textScaleFactor; final iconSize = IconTheme.of(context).size! * textScaleFactor;
return Row( return Row(
children: [ children: [
if (checked != null) ...[ if (checked != null) ...[
Opacity( Opacity(
opacity: checked ? 1 : 0, opacity: checked! ? 1 : 0,
child: Icon(AIcons.checked, size: iconSize), child: Icon(AIcons.checked, size: iconSize),
), ),
SizedBox(width: 8), SizedBox(width: 8),

Some files were not shown because too many files have changed in this diff Show more