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
- name: Unit tests.
run: flutter test
run: flutter test --no-sound-null-safety

View file

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

View file

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

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']
List<String> toDMS(LatLng latLng) {
if (latLng == null) return [];
final lat = latLng.latitude;
final lng = latLng.longitude;
return [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,31 +1,9 @@
// @dart=2.9
import 'dart:isolate';
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_analytics/observer.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -41,147 +19,3 @@ void main() {
runApp(AvesApp());
}
class AvesApp extends StatefulWidget {
@override
_AvesAppState createState() => _AvesAppState();
}
class _AvesAppState extends State<AvesApp> {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
final Set<String> changedUris = {};
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
@override
void initState() {
super.initState();
initPlatformServices();
_appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
}
@override
Widget build(BuildContext context) {
// place the settings provider above `MaterialApp`
// so it can be used during navigation transitions
return ChangeNotifierProvider<Settings>.value(
value: settings,
child: ListenableProvider<ValueNotifier<AppMode>>.value(
value: appModeNotifier,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: HighlightInfoProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
final home = initialized
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox(),
);
return Selector<Settings, Locale>(
selector: (context, s) => s.locale,
builder: (context, settingsLocale, child) {
return MaterialApp(
navigatorKey: _navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
onGenerateTitle: (context) => context.l10n.appName,
darkTheme: Themes.darkTheme,
themeMode: ThemeMode.dark,
locale: settingsLocale,
localizationsDelegates: [
...AppLocalizations.localizationsDelegates,
],
supportedLocales: AppLocalizations.supportedLocales,
);
});
},
),
),
),
),
),
);
}
Widget _buildError(Object error) {
return Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(AIcons.error),
SizedBox(height: 16),
Text(error.toString()),
],
),
);
}
Future<void> _setup() async {
await Firebase.initializeApp().then((app) {
final crashlytics = FirebaseCrashlytics.instance;
FlutterError.onError = crashlytics.recordFlutterError;
crashlytics.setCustomKey('locales', window.locales.join(', '));
final now = DateTime.now();
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
crashlytics.setCustomKey(
'build_mode',
kReleaseMode
? 'release'
: kProfileMode
? 'profile'
: 'debug');
});
await settings.init();
await settings.initFirebase();
_navigatorObservers = [
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
CrashlyticsRouteTracker(),
];
}
void _onNewIntent(Map intentData) {
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
// do not reset when relaunching the app
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
FirebaseCrashlytics.instance.log('New intent');
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
settings: RouteSettings(name: HomePage.routeName),
builder: (_) => getFirstPage(intentData: intentData),
));
}
void _onContentChange(String uri) {
if (uri != null) changedUris.add(uri);
if (changedUris.isNotEmpty) {
_contentChangeDebouncer(() async {
final todo = changedUris.toSet();
changedUris.clear();
final tempUris = await _mediaStoreSource.refreshUris(todo);
if (tempUris.isNotEmpty) {
changedUris.addAll(tempUris);
_onContentChange(null);
}
});
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
TagFilter.type,
];
static CollectionFilter fromJson(String jsonString) {
static CollectionFilter? fromJson(String jsonString) {
final jsonMap = jsonDecode(jsonString);
final type = jsonMap['type'];
switch (type) {
@ -63,7 +63,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String getTooltip(BuildContext context) => getLabel(context);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));
@ -84,7 +84,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
class FilterGridItem<T extends CollectionFilter> {
final T filter;
final AvesEntry entry;
final AvesEntry? entry;
const FilterGridItem(this.filter, this.entry);

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ class TagFilter extends CollectionFilter {
static const type = 'tag';
final String tag;
EntryFilter _test;
late EntryFilter _test;
TagFilter(this.tag) {
if (tag.isEmpty) {
@ -42,7 +42,7 @@ class TagFilter extends CollectionFilter {
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
@override
String get category => type;

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
class DateMetadata {
final int contentId, dateMillis;
final int? contentId, dateMillis;
DateMetadata({
this.contentId,
@ -28,13 +28,13 @@ class DateMetadata {
}
class CatalogMetadata {
final int contentId, dateMillis;
final int? contentId, dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped;
int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription;
double latitude, longitude;
Address address;
int? rotationDegrees;
final String? mimeType, xmpSubjects, xmpTitleDescription;
double? latitude, longitude;
Address? address;
static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0;
@ -55,23 +55,28 @@ class CatalogMetadata {
this.rotationDegrees,
this.xmpSubjects,
this.xmpTitleDescription,
double latitude,
double longitude,
double? latitude,
double? longitude,
}) {
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky values like `1.7056881853375E7`
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7`
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
// but Flutter's `precisionErrorTolerance` (1e-10) is slightly too lenient for this case.
if (latitude != null && longitude != null && (latitude.abs() > _precisionErrorTolerance || longitude.abs() > _precisionErrorTolerance)) {
this.latitude = latitude < -90.0 || latitude > 90.0 ? null : latitude;
this.longitude = longitude < -180.0 || longitude > 180.0 ? null : longitude;
// funny case: some files have latitude and longitude reverse
// (e.g. a Japanese location at lat~=133 and long~=34, which is a valid longitude but an invalid latitude)
// so we should check and assign both coordinates at once
if (latitude >= -90.0 && latitude <= 90.0 && longitude >= -180.0 && longitude <= 180.0) {
this.latitude = latitude;
this.longitude = longitude;
}
}
}
CatalogMetadata copyWith({
int contentId,
String mimeType,
bool isMultiPage,
int rotationDegrees,
int? contentId,
String? mimeType,
bool? isMultiPage,
int? rotationDegrees,
}) {
return CatalogMetadata(
contentId: contentId ?? this.contentId,
@ -127,16 +132,16 @@ class CatalogMetadata {
}
class OverlayMetadata {
final String aperture, exposureTime, focalLength, iso;
final String? aperture, exposureTime, focalLength, iso;
static final apertureFormat = NumberFormat('0.0', 'en_US');
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
OverlayMetadata({
double aperture,
String exposureTime,
double focalLength,
int iso,
double? aperture,
String? exposureTime,
double? focalLength,
int? iso,
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
exposureTime = exposureTime,
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
@ -144,10 +149,10 @@ class OverlayMetadata {
factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata(
aperture: map['aperture'] as double,
exposureTime: map['exposureTime'] as String,
focalLength: map['focalLength'] as double,
iso: map['iso'] as int,
aperture: map['aperture'] as double?,
exposureTime: map['exposureTime'] as String?,
focalLength: map['focalLength'] as double?,
iso: map['iso'] as int?,
);
}
@ -159,10 +164,10 @@ class OverlayMetadata {
@immutable
class AddressDetails {
final int contentId;
final String countryCode, countryName, adminArea, locality;
final int? contentId;
final String? countryCode, countryName, adminArea, locality;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
const AddressDetails({
this.contentId,
@ -173,7 +178,7 @@ class AddressDetails {
});
AddressDetails copyWith({
int contentId,
int? contentId,
}) {
return AddressDetails(
contentId: contentId ?? this.contentId,
@ -186,11 +191,11 @@ class AddressDetails {
factory AddressDetails.fromMap(Map map) {
return AddressDetails(
contentId: map['contentId'],
countryCode: map['countryCode'] ?? '',
countryName: map['countryName'] ?? '',
adminArea: map['adminArea'] ?? '',
locality: map['locality'] ?? '',
contentId: map['contentId'] as int?,
countryCode: map['countryCode'] as String?,
countryName: map['countryName'] as String?,
adminArea: map['adminArea'] as String?,
locality: map['locality'] as String?,
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ class AndroidAppService {
final result = await platform.invokeMethod('getPackages');
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
// additional info for known directories
final kakaoTalk = packages.firstWhere((package) => package.packageName == 'com.kakao.talk', orElse: () => null);
final kakaoTalk = packages.firstWhereOrNull((package) => package.packageName == 'com.kakao.talk');
if (kakaoTalk != null) {
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
}
@ -31,19 +31,20 @@ class AndroidAppService {
'packageName': packageName,
'sizeDip': size,
});
return result as Uint8List;
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
return Uint8List(0);
}
static Future<bool> edit(String uri, String mimeType) async {
try {
return await platform.invokeMethod('edit', <String, dynamic>{
final result = await platform.invokeMethod('edit', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -52,10 +53,11 @@ class AndroidAppService {
static Future<bool> open(String uri, String mimeType) async {
try {
return await platform.invokeMethod('open', <String, dynamic>{
final result = await platform.invokeMethod('open', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -64,9 +66,10 @@ class AndroidAppService {
static Future<bool> openMap(String geoUri) async {
try {
return await platform.invokeMethod('openMap', <String, dynamic>{
final result = await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -75,10 +78,11 @@ class AndroidAppService {
static Future<bool> setAs(String uri, String mimeType) async {
try {
return await platform.invokeMethod('setAs', <String, dynamic>{
final result = await platform.invokeMethod('setAs', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -90,9 +94,10 @@ class AndroidAppService {
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try {
return await platform.invokeMethod('share', <String, dynamic>{
final result = await platform.invokeMethod('share', <String, dynamic>{
'urisByMimeType': urisByMimeType,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
@ -101,11 +106,12 @@ class AndroidAppService {
static Future<bool> shareSingle(String uri, String mimeType) async {
try {
return await platform.invokeMethod('share', <String, dynamic>{
final result = await platform.invokeMethod('share', <String, dynamic>{
'urisByMimeType': {
mimeType: [uri]
},
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@ class GeocodingService {
@immutable
class Address {
final String addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare;
final String? addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare;
const Address({
this.addressLine,

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
class AChangeNotifier implements Listenable {
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>();
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
@override
void addListener(VoidCallback listener) => _listeners.add(listener);
void addListener(VoidCallback listener) => _listeners!.add(listener);
@override
void removeListener(VoidCallback listener) => _listeners.remove(listener);
void removeListener(VoidCallback listener) => _listeners!.remove(listener);
void dispose() => _listeners = null;
void notifyListeners() {
if (_listeners == null) return;
final localListeners = List<VoidCallback>.from(_listeners);
final localListeners = List<VoidCallback>.from(_listeners!);
for (final listener in localListeners) {
try {
if (_listeners.contains(listener)) listener();
if (_listeners!.contains(listener)) listener();
} catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
}

View file

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

View file

@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
class Debouncer {
final Duration delay;
Timer _timer;
Timer? _timer;
Debouncer({@required this.delay});
Debouncer({required this.delay});
void call(Function action) {
void call(VoidCallback action) {
_timer?.cancel();
_timer = Timer(delay, action);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -13,8 +13,8 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
final double offsetY;
const CollectionDraggableThumbLabel({
@required this.collection,
@required this.offsetY,
required this.collection,
required this.offsetY,
});
@override
@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
case EntryGroupFactor.album:
return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
];
case EntryGroupFactor.month:
case EntryGroupFactor.none:
@ -40,21 +40,23 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
DraggableThumbLabel.formatDayThumbLabel(context, entry.bestDate),
];
}
break;
case EntrySortFactor.name:
return [
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
entry.bestTitle,
if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
if (entry.bestTitle != null) entry.bestTitle!,
];
case EntrySortFactor.size:
return [
formatFilesize(entry.sizeBytes, round: 0),
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0),
];
}
return [];
},
);
}
bool _hasMultipleSections(BuildContext context) => context.read<SectionedListLayout<AvesEntry>>().sections.length > 1;
bool _showAlbumName(BuildContext context, AvesEntry entry) => _hasMultipleSections(context) && entry.directory != null;
String _getAlbumName(BuildContext context, AvesEntry entry) => context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory!);
}

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

View file

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

View file

@ -2,36 +2,40 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/grid/header.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
class AlbumSectionHeader extends StatelessWidget {
final String directory, albumName;
final String? directory, albumName;
const AlbumSectionHeader({
Key key,
@required this.directory,
@required this.albumName,
Key? key,
required this.directory,
required this.albumName,
}) : super(key: key);
@override
Widget build(BuildContext context) {
var albumIcon = IconUtils.getAlbumIcon(context: context, album: directory);
if (albumIcon != null) {
albumIcon = Material(
type: MaterialType.circle,
elevation: 3,
color: Colors.transparent,
shadowColor: Colors.black,
child: albumIcon,
);
Widget? albumIcon;
if (directory != null) {
albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!);
if (albumIcon != null) {
albumIcon = Material(
type: MaterialType.circle,
elevation: 3,
color: Colors.transparent,
shadowColor: Colors.black,
child: albumIcon,
);
}
}
return SectionHeader(
sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon,
title: albumName,
trailing: androidFileUtils.isOnRemovableStorage(directory)
title: albumName ?? context.l10n.sectionUnknown,
trailing: directory != null && androidFileUtils.isOnRemovableStorage(directory!)
? Icon(
AIcons.removableStorage,
size: 16,
@ -42,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget {
}
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
final directory = sectionKey.directory;
final directory = sectionKey.directory ?? context.l10n.sectionUnknown;
return SectionHeader.getPreferredHeight(
context: context,
maxWidth: maxWidth,

View file

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

View file

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

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/widgets/collection/grid/headers/any.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
final CollectionLens collection;
const SectionedEntryListLayoutProvider({
@required this.collection,
@required double scrollableWidth,
@required int columnCount,
@required double tileExtent,
@required Widget Function(AvesEntry entry) tileBuilder,
@required Duration tileAnimationDelay,
@required Widget child,
required this.collection,
required double scrollableWidth,
required int columnCount,
required double tileExtent,
required Widget Function(AvesEntry entry) tileBuilder,
required Duration tileAnimationDelay,
required Widget child,
}) : super(
scrollableWidth: scrollableWidth,
columnCount: columnCount,

View file

@ -19,10 +19,10 @@ class GridSelectionGestureDetector extends StatefulWidget {
const GridSelectionGestureDetector({
this.selectable = true,
@required this.collection,
@required this.scrollController,
@required this.appBarHeightNotifier,
@required this.child,
required this.collection,
required this.scrollController,
required this.appBarHeightNotifier,
required this.child,
});
@override
@ -30,16 +30,16 @@ class GridSelectionGestureDetector extends StatefulWidget {
}
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
bool _pressing = false, _selecting;
int _fromIndex, _lastToIndex;
Offset _localPosition;
EdgeInsets _scrollableInsets;
double _scrollSpeedFactor;
Timer _updateTimer;
bool _pressing = false, _selecting = false;
late int _fromIndex, _lastToIndex;
late Offset _localPosition;
late EdgeInsets _scrollableInsets;
late double _scrollSpeedFactor;
Timer? _updateTimer;
CollectionLens get collection => widget.collection;
List<AvesEntry> get entries => collection.sortedEntries;
List<AvesEntry > get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController;
@ -102,7 +102,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
}
final toEntry = _getEntryAt(_localPosition);
_toggleSelectionToIndex(entries.indexOf(toEntry));
if (toEntry != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry));
}
}
void _setScrollSpeed(double speedFactor) {
@ -131,7 +133,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
}
}
AvesEntry _getEntryAt(Offset localPosition) {
AvesEntry? _getEntryAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,14 +15,19 @@ import 'package:flutter/widgets.dart';
mixin SizeAwareMixin {
Future<bool> checkFreeSpaceForMove(
BuildContext context,
Set<AvesEntry> selection,
Set<AvesEntry > selection,
String destinationAlbum,
MoveType moveType,
) async {
// assume we have enough space if we cannot find the volume or its remaining free space
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
if (destinationVolume == null) return true;
final free = await storageService.getFreeSpace(destinationVolume);
int needed;
int sumSize(sum, entry) => sum + entry.sizeBytes;
if (free == null) return true;
late int needed;
int sumSize(sum, entry) => sum + entry.sizeBytes ?? 0;
switch (moveType) {
case MoveType.copy:
case MoveType.export:
@ -30,11 +35,11 @@ mixin SizeAwareMixin {
break;
case MoveType.move:
// when moving, we only need space for the entries that are not already on the destination volume
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
final byVolume = Map.fromEntries(groupBy<AvesEntry, StorageVolume?>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)).entries.where((kv) => kv.key != null).cast<MapEntry<StorageVolume, List<AvesEntry>>>());
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume]!.fold(0, sumSize));
// and we need at least as much space as the largest entry because individual entries are copied then deleted
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes ?? 0));
needed = max(fromOtherVolumes, largestSingle);
break;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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