#36 set filter cover + service IoC + collection source tests

This commit is contained in:
Thibault Deckers 2021-03-17 17:42:02 +09:00
parent cf8d182cfe
commit 87f1eb6cc7
76 changed files with 1658 additions and 452 deletions

9
lib/app_mode.dart Normal file
View file

@ -0,0 +1,9 @@
enum AppMode { main, pickExternal, pickInternal, view }
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;
}

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -32,7 +32,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getRegion( final bytes = await imageFileService.getRegion(
uri, uri,
mimeType, mimeType,
key.rotationDegrees, key.rotationDegrees,
@ -55,11 +55,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key); imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelRegion(key); void pause() => imageFileService.cancelRegion(key);
} }
class RegionProviderKey { class RegionProviderKey {

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -33,7 +33,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getThumbnail( final bytes = await imageFileService.getThumbnail(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
@ -55,11 +55,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key); imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelThumbnail(key); void pause() => imageFileService.cancelThumbnail(key);
} }
class ThumbnailProviderKey { class ThumbnailProviderKey {

View file

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
@ -46,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
assert(key == this); assert(key == this);
try { try {
final bytes = await ImageFileService.getImage( final bytes = await imageFileService.getImage(
uri, uri,
mimeType, mimeType,
rotationDegrees, rotationDegrees,

View file

@ -1,4 +1,4 @@
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@ -29,7 +29,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async { Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this); assert(key == this);
final data = await ImageFileService.getSvg(uri, mimeType); final data = await imageFileService.getSvg(uri, mimeType);
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
return null; return null;
} }

View file

@ -49,6 +49,8 @@
"@chipActionUnpin": {}, "@chipActionUnpin": {},
"chipActionRename": "Rename", "chipActionRename": "Rename",
"@chipActionRename": {}, "@chipActionRename": {},
"chipActionSetCover": "Set cover",
"@chipActionSetCover": {},
"entryActionDelete": "Delete", "entryActionDelete": "Delete",
"@entryActionDelete": {}, "@entryActionDelete": {},
@ -210,6 +212,13 @@
} }
}, },
"setCoverDialogTitle": "Set Cover",
"@setCoverDialogTitle": {},
"setCoverDialogLatest": "Latest item",
"@setCoverDialogLatest": {},
"setCoverDialogCustom": "Custom",
"@setCoverDialogCustom": {},
"hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?", "hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?",
"@hideFilterConfirmationDialogMessage": {}, "@hideFilterConfirmationDialogMessage": {},

View file

@ -26,6 +26,7 @@
"chipActionPin": "고정", "chipActionPin": "고정",
"chipActionUnpin": "고정 해제", "chipActionUnpin": "고정 해제",
"chipActionRename": "이름 변경", "chipActionRename": "이름 변경",
"chipActionSetCover": "대표 이미지 변경",
"entryActionDelete": "삭제", "entryActionDelete": "삭제",
"entryActionExport": "내보내기", "entryActionExport": "내보내기",
@ -89,6 +90,10 @@
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
"setCoverDialogTitle": "대표 이미지 변경",
"setCoverDialogLatest": "최근 항목",
"setCoverDialogCustom": "직접 설정",
"newAlbumDialogTitle": "새 앨범 만들기", "newAlbumDialogTitle": "새 앨범 만들기",
"newAlbumDialogNameLabel": "앨범 이름", "newAlbumDialogNameLabel": "앨범 이름",
"newAlbumDialogNameLabelAlreadyExistsHelper": "사용 중인 이름입니다", "newAlbumDialogNameLabelAlreadyExistsHelper": "사용 중인 이름입니다",

View file

@ -1,11 +1,14 @@
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_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/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
@ -40,8 +43,6 @@ void main() {
runApp(AvesApp()); runApp(AvesApp());
} }
enum AppMode { main, pick, view }
class AvesApp extends StatefulWidget { class AvesApp extends StatefulWidget {
@override @override
_AvesAppState createState() => _AvesAppState(); _AvesAppState createState() => _AvesAppState();
@ -61,56 +62,12 @@ class _AvesAppState extends State<AvesApp> {
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent'); final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
static const accentColor = Colors.indigoAccent;
static final darkTheme = ThemeData(
brightness: Brightness.dark,
accentColor: accentColor,
scaffoldBackgroundColor: Colors.grey[900],
buttonColor: accentColor,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: accentColor,
tooltipTheme: TooltipThemeData(
verticalOffset: 32,
),
appBarTheme: AppBarTheme(
textTheme: TextTheme(
headline6: TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
color: Colors.white,
),
behavior: SnackBarBehavior.floating,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,
),
),
);
Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage(); Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
initPlatformServices();
_appSetup = _setup(); _appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String)); _contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
@ -145,7 +102,7 @@ class _AvesAppState extends State<AvesApp> {
home: home, home: home,
navigatorObservers: _navigatorObservers, navigatorObservers: _navigatorObservers,
onGenerateTitle: (context) => context.l10n.appName, onGenerateTitle: (context) => context.l10n.appName,
darkTheme: darkTheme, darkTheme: Themes.darkTheme,
themeMode: ThemeMode.dark, themeMode: ThemeMode.dark,
locale: settingsLocale, locale: settingsLocale,
localizationsDelegates: [ localizationsDelegates: [

View file

@ -14,6 +14,7 @@ enum ChipAction {
pin, pin,
unpin, unpin,
rename, rename,
setCover,
goToAlbumPage, goToAlbumPage,
goToCountryPage, goToCountryPage,
goToTagPage, goToTagPage,
@ -38,6 +39,8 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionUnpin; return context.l10n.chipActionUnpin;
case ChipAction.rename: case ChipAction.rename:
return context.l10n.chipActionRename; return context.l10n.chipActionRename;
case ChipAction.setCover:
return context.l10n.chipActionSetCover;
} }
return null; return null;
} }
@ -59,6 +62,8 @@ extension ExtraChipAction on ChipAction {
return AIcons.pin; return AIcons.pin;
case ChipAction.rename: case ChipAction.rename:
return AIcons.rename; return AIcons.rename;
case ChipAction.setCover:
return AIcons.setCover;
} }
return null; return null;
} }

View file

@ -8,17 +8,29 @@ import 'package:google_api_availability/google_api_availability.dart';
import 'package:package_info/package_info.dart'; import 'package:package_info/package_info.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
final AvesAvailability availability = AvesAvailability._private(); abstract class AvesAvailability {
void onResume();
class AvesAvailability { Future<bool> get isConnected;
Future<bool> get hasPlayServices;
Future<bool> get canLocatePlaces;
Future<bool> get isNewVersionAvailable;
}
class LiveAvesAvailability implements AvesAvailability {
bool _isConnected, _hasPlayServices, _isNewVersionAvailable; bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
AvesAvailability._private() { LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
} }
@override
void onResume() => _isConnected = null; void onResume() => _isConnected = null;
@override
Future<bool> get isConnected async { Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected); if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity()); final result = await (Connectivity().checkConnectivity());
@ -34,6 +46,7 @@ class AvesAvailability {
} }
} }
@override
Future<bool> get hasPlayServices async { Future<bool> get hasPlayServices async {
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices); if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability(); final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
@ -43,8 +56,10 @@ class AvesAvailability {
} }
// local geocoding with `geocoder` requires Play Services // local geocoding with `geocoder` requires Play Services
@override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get isNewVersionAvailable async { Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);

111
lib/model/covers.dart Normal file
View file

@ -0,0 +1,111 @@
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:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final Covers covers = Covers._private();
class Covers with ChangeNotifier {
Set<CoverRow> _rows = {};
Covers._private();
Future<void> init() async {
_rows = await metadataDb.loadCovers();
}
int get count => _rows.length;
int coverContentId(CollectionFilter filter) => _rows.firstWhere((row) => row.filter == filter, orElse: () => null)?.contentId;
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);
}
final row = CoverRow(filter: filter, contentId: contentId);
_rows.removeWhere((row) => row.filter == filter);
if (contentId == null) {
await metadataDb.removeCovers({row});
} else {
_rows.add(row);
await metadataDb.addCovers({row});
}
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet();
if (oldRows.isEmpty) return;
for (final oldRow in oldRows) {
final filter = oldRow.filter;
_rows.remove(oldRow);
if (filter.test(entry)) {
final newRow = CoverRow(filter: filter, contentId: entry.contentId);
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
_rows.add(newRow);
} else {
await metadataDb.removeCovers({oldRow});
}
}
notifyListeners();
}
Future<void> removeEntries(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
await metadataDb.removeCovers(removedRows);
_rows.removeAll(removedRows);
notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearCovers();
_rows.clear();
notifyListeners();
}
}
@immutable
class CoverRow {
final CollectionFilter filter;
final int contentId;
const CoverRow({
@required this.filter,
@required this.contentId,
});
factory CoverRow.fromMap(Map map) {
return CoverRow(
filter: CollectionFilter.fromJson(map['filter']),
contentId: map['contentId'],
);
}
Map<String, dynamic> toMap() => {
'filter': filter.toJson(),
'contentId': contentId,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is CoverRow && other.filter == filter && other.contentId == contentId;
}
@override
int get hashCode => hashValues(filter, contentId);
@override
String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}';
}

View file

@ -3,15 +3,13 @@ import 'dart:async';
import 'package:aves/geo/countries.dart'; import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
@ -34,7 +32,7 @@ class AvesEntry {
int height; int height;
int sourceRotationDegrees; int sourceRotationDegrees;
final int sizeBytes; final int sizeBytes;
String sourceTitle; String _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode // `dateModifiedSecs` can be missing in viewer mode
int _dateModifiedSecs; int _dateModifiedSecs;
@ -59,13 +57,14 @@ class AvesEntry {
@required this.height, @required this.height,
this.sourceRotationDegrees, this.sourceRotationDegrees,
this.sizeBytes, this.sizeBytes,
this.sourceTitle, String sourceTitle,
int dateModifiedSecs, int dateModifiedSecs,
this.sourceDateTakenMillis, this.sourceDateTakenMillis,
this.durationMillis, this.durationMillis,
}) : assert(width != null), }) : assert(width != null),
assert(height != null) { assert(height != null) {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs; this.dateModifiedSecs = dateModifiedSecs;
} }
@ -74,14 +73,14 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({ AvesEntry copyWith({
@required String uri, String uri,
@required String path, String path,
@required int contentId, int contentId,
@required int dateModifiedSecs, int dateModifiedSecs,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry( final copied = AvesEntry(
uri: uri ?? uri, uri: uri ?? this.uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: copyContentId,
sourceMimeType: sourceMimeType, sourceMimeType: sourceMimeType,
@ -90,7 +89,7 @@ class AvesEntry {
sourceRotationDegrees: sourceRotationDegrees, sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
sourceTitle: sourceTitle, sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
) )
@ -342,6 +341,13 @@ class AvesEntry {
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
String get sourceTitle => _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) {
@ -439,7 +445,7 @@ class AvesEntry {
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);
} else { } else {
catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
} }
} }
@ -553,10 +559,7 @@ class AvesEntry {
final contentId = newFields['contentId']; final contentId = newFields['contentId'];
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields['title'];
if (sourceTitle is String) { if (sourceTitle is String) this.sourceTitle = sourceTitle;
this.sourceTitle = sourceTitle;
_bestTitle = null;
}
final width = newFields['width']; final width = newFields['width'];
if (width is int) this.width = width; if (width is int) this.width = width;
@ -576,18 +579,8 @@ class AvesEntry {
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
await _applyNewFields(newFields);
return true;
}
Future<bool> rotate({@required bool clockwise}) async { Future<bool> rotate({@required bool clockwise}) async {
final newFields = await ImageFileService.rotate(this, clockwise: clockwise); final newFields = await imageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -599,7 +592,7 @@ class AvesEntry {
} }
Future<bool> flip() async { Future<bool> flip() async {
final newFields = await ImageFileService.flip(this); final newFields = await imageFileService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -612,7 +605,7 @@ class AvesEntry {
Future<bool> delete() { Future<bool> delete() {
Completer completer = Completer<bool>(); Completer completer = Completer<bool>();
ImageFileService.delete([this]).listen( imageFileService.delete([this]).listen(
(event) => completer.complete(event.success), (event) => completer.complete(event.success),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
@ -634,23 +627,23 @@ class AvesEntry {
// favourites // favourites
void toggleFavourite() { Future<void> toggleFavourite() async {
if (isFavourite) { if (isFavourite) {
removeFromFavourites(); await removeFromFavourites();
} else { } else {
addToFavourites(); await addToFavourites();
} }
} }
void addToFavourites() { Future<void> addToFavourites() async {
if (!isFavourite) { if (!isFavourite) {
favourites.add([this]); await favourites.add([this]);
} }
} }
void removeFromFavourites() { Future<void> removeFromFavourites() async {
if (isFavourite) { if (isFavourite) {
favourites.remove([this]); await favourites.remove([this]);
} }
} }

View file

@ -1,60 +0,0 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/utils/change_notifier.dart';
final FavouriteRepo favourites = FavouriteRepo._private();
class FavouriteRepo {
List<FavouriteRow> _rows = [];
final AChangeNotifier changeNotifier = AChangeNotifier();
FavouriteRepo._private();
Future<void> init() async {
_rows = await metadataDb.loadFavourites();
}
int get count => _rows.length;
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
changeNotifier.notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final removedRows = entries.map(_entryToRow);
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
changeNotifier.notifyListeners();
}
Future<void> move(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
changeNotifier.notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
changeNotifier.notifyListeners();
}
}

96
lib/model/favourites.dart Normal file
View file

@ -0,0 +1,96 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final Favourites favourites = Favourites._private();
class Favourites with ChangeNotifier {
Set<FavouriteRow> _rows = {};
Favourites._private();
Future<void> init() async {
_rows = await metadataDb.loadFavourites();
}
int get count => _rows.length;
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
if (oldRow == null) return;
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
notifyListeners();
}
}
@immutable
class FavouriteRow {
final int contentId;
final String path;
const FavouriteRow({
this.contentId,
this.path,
});
factory FavouriteRow.fromMap(Map map) {
return FavouriteRow(
contentId: map['contentId'],
path: map['path'] ?? '',
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'path': path,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteRow && other.contentId == contentId && other.path == path;
}
@override
int get hashCode => hashValues(contentId, path);
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
}

View file

@ -204,38 +204,3 @@ class AddressDetails {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
} }
@immutable
class FavouriteRow {
final int contentId;
final String path;
const FavouriteRow({
this.contentId,
this.path,
});
factory FavouriteRow.fromMap(Map map) {
return FavouriteRow(
contentId: map['contentId'],
path: map['path'] ?? '',
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'path': path,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteRow && other.contentId == contentId && other.path == path;
}
@override
int get hashCode => hashValues(contentId, path);
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
}

View file

@ -1,15 +1,85 @@
import 'dart:io'; import 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
final MetadataDb metadataDb = MetadataDb._private(); abstract class MetadataDb {
Future<void> init();
class MetadataDb { Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly});
// entries
Future<void> clearEntries();
Future<Set<AvesEntry>> loadEntries();
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntryId(int oldId, AvesEntry entry);
// date taken
Future<void> clearDates();
Future<List<DateMetadata>> loadDates();
// catalog metadata
Future<void> clearMetadataEntries();
Future<List<CatalogMetadata>> loadMetadataEntries();
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata);
// address
Future<void> clearAddresses();
Future<List<AddressDetails>> loadAddresses();
Future<void> saveAddresses(Iterable<AddressDetails> addresses);
Future<void> updateAddressId(int oldId, AddressDetails address);
// favourites
Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
// covers
Future<void> clearCovers();
Future<Set<CoverRow>> loadCovers();
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Iterable<CoverRow> rows);
}
class SqfliteMetadataDb implements MetadataDb {
Future<Database> _database; Future<Database> _database;
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db'); Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
@ -19,9 +89,9 @@ class MetadataDb {
static const metadataTable = 'metadata'; static const metadataTable = 'metadata';
static const addressTable = 'address'; static const addressTable = 'address';
static const favouriteTable = 'favourites'; static const favouriteTable = 'favourites';
static const coverTable = 'covers';
MetadataDb._private(); @override
Future<void> init() async { Future<void> init() async {
debugPrint('$runtimeType init'); debugPrint('$runtimeType init');
_database = openDatabase( _database = openDatabase(
@ -68,17 +138,23 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', path TEXT' ', path TEXT'
')'); ')');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}, },
onUpgrade: MetadataDbUpgrader.upgradeDb, onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 3, version: 4,
); );
} }
@override
Future<int> dbFileSize() async { Future<int> dbFileSize() async {
final file = File((await path)); final file = File((await path));
return await file.exists() ? file.length() : 0; return await file.exists() ? file.length() : 0;
} }
@override
Future<void> reset() async { Future<void> reset() async {
debugPrint('$runtimeType reset'); debugPrint('$runtimeType reset');
await (await _database).close(); await (await _database).close();
@ -86,7 +162,8 @@ class MetadataDb {
await init(); await init();
} }
void removeIds(Set<int> contentIds, {@required bool updateFavourites}) async { @override
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) async {
if (contentIds == null || contentIds.isEmpty) return; if (contentIds == null || contentIds.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -100,8 +177,9 @@ class MetadataDb {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs); batch.delete(metadataTable, where: where, whereArgs: whereArgs);
batch.delete(addressTable, where: where, whereArgs: whereArgs); batch.delete(addressTable, where: where, whereArgs: whereArgs);
if (updateFavourites) { if (!metadataOnly) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs);
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
@ -110,12 +188,14 @@ class MetadataDb {
// entries // entries
@override
Future<void> clearEntries() async { Future<void> clearEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(entryTable, where: '1'); final count = await db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count entries'); debugPrint('$runtimeType clearEntries deleted $count entries');
} }
@override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -125,6 +205,7 @@ class MetadataDb {
return entries; return entries;
} }
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) async { Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries == null || entries.isEmpty) return; if (entries == null || entries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -135,6 +216,7 @@ class MetadataDb {
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
} }
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) async { Future<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -154,12 +236,14 @@ class MetadataDb {
// date taken // date taken
@override
Future<void> clearDates() async { Future<void> clearDates() async {
final db = await _database; final db = await _database;
final count = await db.delete(dateTakenTable, where: '1'); final count = await db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count entries'); debugPrint('$runtimeType clearDates deleted $count entries');
} }
@override
Future<List<DateMetadata>> loadDates() async { Future<List<DateMetadata>> loadDates() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -171,12 +255,14 @@ class MetadataDb {
// catalog metadata // catalog metadata
@override
Future<void> clearMetadataEntries() async { Future<void> clearMetadataEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(metadataTable, where: '1'); final count = await db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
} }
@override
Future<List<CatalogMetadata>> loadMetadataEntries() async { Future<List<CatalogMetadata>> loadMetadataEntries() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -186,6 +272,7 @@ class MetadataDb {
return metadataEntries; return metadataEntries;
} }
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async { Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return; if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -200,6 +287,7 @@ class MetadataDb {
} }
} }
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async { Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -227,12 +315,14 @@ class MetadataDb {
// address // address
@override
Future<void> clearAddresses() async { Future<void> clearAddresses() async {
final db = await _database; final db = await _database;
final count = await db.delete(addressTable, where: '1'); final count = await db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count entries'); debugPrint('$runtimeType clearAddresses deleted $count entries');
} }
@override
Future<List<AddressDetails>> loadAddresses() async { Future<List<AddressDetails>> loadAddresses() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -242,6 +332,7 @@ class MetadataDb {
return addresses; return addresses;
} }
@override
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async { Future<void> saveAddresses(Iterable<AddressDetails> addresses) async {
if (addresses == null || addresses.isEmpty) return; if (addresses == null || addresses.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -252,6 +343,7 @@ class MetadataDb {
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
} }
@override
Future<void> updateAddressId(int oldId, AddressDetails address) async { Future<void> updateAddressId(int oldId, AddressDetails address) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -271,31 +363,31 @@ class MetadataDb {
// favourites // favourites
@override
Future<void> clearFavourites() async { Future<void> clearFavourites() async {
final db = await _database; final db = await _database;
final count = await db.delete(favouriteTable, where: '1'); final count = await db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count entries'); debugPrint('$runtimeType clearFavourites deleted $count entries');
} }
Future<List<FavouriteRow>> loadFavourites() async { @override
// final stopwatch = Stopwatch()..start(); Future<Set<FavouriteRow>> loadFavourites() async {
final db = await _database; final db = await _database;
final maps = await db.query(favouriteTable); final maps = await db.query(favouriteTable);
final favouriteRows = maps.map((map) => FavouriteRow.fromMap(map)).toList(); final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
// debugPrint('$runtimeType loadFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); return rows;
return favouriteRows;
} }
Future<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async { @override
if (favouriteRows == null || favouriteRows.isEmpty) return; Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
// final stopwatch = Stopwatch()..start(); if (rows == null || rows.isEmpty) return;
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
favouriteRows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row)); rows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row));
await batch.commit(noResult: true); await batch.commit(noResult: true);
// debugPrint('$runtimeType addFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries');
} }
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async { Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -313,9 +405,10 @@ class MetadataDb {
); );
} }
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async { @override
if (favouriteRows == null || favouriteRows.isEmpty) return; Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); if (rows == null || rows.isEmpty) return;
final ids = rows.where((row) => row != null).map((row) => row.contentId);
if (ids.isEmpty) return; if (ids.isEmpty) return;
final db = await _database; final db = await _database;
@ -324,4 +417,61 @@ class MetadataDb {
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
// covers
@override
Future<void> clearCovers() async {
final db = await _database;
final count = await db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count entries');
}
@override
Future<Set<CoverRow>> loadCovers() async {
final db = await _database;
final maps = await db.query(coverTable);
final rows = maps.map((map) => CoverRow.fromMap(map)).toSet();
return rows;
}
@override
Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows == null || rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
rows.where((row) => row != null).forEach((row) => _batchInsertCover(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertCover(batch, row);
await batch.commit(noResult: true);
}
void _batchInsertCover(Batch batch, CoverRow row) {
if (row == null) return;
batch.insert(
coverTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@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);
if (filters.isEmpty) return;
final db = await _database;
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
await batch.commit(noResult: true);
}
} }

View file

@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader { class MetadataDbUpgrader {
static const entryTable = MetadataDb.entryTable; static const entryTable = SqfliteMetadataDb.entryTable;
static const metadataTable = MetadataDb.metadataTable; static const metadataTable = SqfliteMetadataDb.metadataTable;
static const coverTable = SqfliteMetadataDb.coverTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices // on SQLite <3.25.0, bundled on older Android devices
@ -17,6 +18,9 @@ class MetadataDbUpgrader {
case 2: case 2:
await _upgradeFrom2(db); await _upgradeFrom2(db);
break; break;
case 3:
await _upgradeFrom3(db);
break;
} }
oldVersion++; oldVersion++;
} }
@ -97,4 +101,12 @@ class MetadataDbUpgrader {
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
}); });
} }
static Future<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}
} }

View file

@ -1,19 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -99,10 +100,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent(entries)); eventBus.fire(EntryAddedEvent(entries));
} }
void removeEntries(Set<String> uris) { Future<void> removeEntries(Set<String> uris) async {
if (uris.isEmpty) return; if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
entries.forEach((entry) => entry.removeFromFavourites()); await favourites.remove(entries);
await covers.removeEntries(entries);
_rawEntries.removeAll(entries); _rawEntries.removeAll(entries);
_invalidate(entries); _invalidate(entries);
@ -121,30 +123,61 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateTags(); updateTags();
} }
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
final oldContentId = entry.contentId; final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int; final newContentId = newFields['contentId'] as int;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.contentId = newContentId;
// `dateModifiedSecs` changes when moving entries to another directory, // `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory // but it does not change when renaming the containing directory
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs; if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.path = newFields['path'] as String; if (newFields.containsKey('path')) entry.path = newFields['path'] as String;
entry.uri = newFields['uri'] as String; if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
entry.contentId = newContentId; if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId); entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId); entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await metadataDb.updateEntryId(oldContentId, entry); await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails); await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
if (isFavourite) { await favourites.moveEntry(oldContentId, entry);
await favourites.move(oldContentId, entry); await covers.moveEntry(oldContentId, entry);
}
Future<bool> renameEntry(AvesEntry entry, String newName) async {
if (newName == entry.filenameWithoutExtension) return true;
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false;
await _moveEntry(entry, newFields);
entry.metadataChangeNotifier.notifyListeners();
return true;
}
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
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;
await updateAfterMove(
todoEntries: todoEntries,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// restore pin and cover, as the obsolete album got removed and its associated state cleaned
final newFilter = AlbumFilter(destinationAlbum, null);
if (pinned) {
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
if (coverEntry != null) {
await covers.set(newFilter, coverEntry.contentId);
} }
} }
Future<void> updateAfterMove({ Future<void> updateAfterMove({
@required Set<AvesEntry> todoEntries, @required Set<AvesEntry> todoEntries,
@required Set<AvesEntry> favouriteEntries,
@required bool copy, @required bool copy,
@required String destinationAlbum, @required String destinationAlbum,
@required Set<MoveOpEvent> movedOps, @required Set<MoveOpEvent> movedOps,
@ -178,10 +211,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (entry != null) { if (entry != null) {
fromAlbums.add(entry.directory); fromAlbums.add(entry.directory);
movedEntries.add(entry); movedEntries.add(entry);
// do not rely on current favourite repo state to assess whether the moved entry is a favourite await _moveEntry(entry, newFields);
// as source monitoring may already have removed the entry from the favourite repo
final isFavourite = favouriteEntries.contains(entry);
await _moveEntry(entry, newFields, isFavourite);
} }
} }
}); });
@ -232,6 +262,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return null; return null;
} }
AvesEntry coverEntry(CollectionFilter filter) {
final contentId = covers.coverContentId(filter);
if (contentId != null) {
final entry = visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
if (entry != null) return entry;
}
return recentEntry(filter);
}
void changeFilterVisibility(CollectionFilter filter, bool visible) { void changeFilterVisibility(CollectionFilter filter, bool visible) {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
if (visible) { if (visible) {

View file

@ -5,9 +5,9 @@ import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';

View file

@ -1,15 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
@ -27,7 +25,8 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
await metadataDb.init(); await metadataDb.init();
await favourites.init(); await favourites.init();
final currentTimeZone = await TimeService.getDefaultTimeZone(); await covers.init();
final currentTimeZone = await timeService.getDefaultTimeZone();
final catalogTimeZone = settings.catalogTimeZone; final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) { if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone // clear catalog metadata to get correct date/times when moving to a different time zone
@ -51,7 +50,7 @@ class MediaStoreSource extends CollectionSource {
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries // show known entries
@ -61,11 +60,11 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries // clean up obsolete entries
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
// verify paths because some apps move files without updating their `last modified date` // verify paths because some apps move files without updating their `last modified date`
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path))); final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
final movedContentIds = (await MediaStoreService.checkObsoletePaths(knownPathById)).toSet(); final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
movedContentIds.forEach((contentId) { movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date // make obsolete by resetting its modified date
knownDateById[contentId] = 0; knownDateById[contentId] = 0;
@ -82,7 +81,7 @@ class MediaStoreSource extends CollectionSource {
pendingNewEntries.clear(); pendingNewEntries.clear();
} }
MediaStoreService.getEntries(knownDateById).listen( mediaStoreService.getEntries(knownDateById).listen(
(entry) { (entry) {
pendingNewEntries.add(entry); pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) { if (pendingNewEntries.length >= refreshCount) {
@ -115,6 +114,7 @@ class MediaStoreSource extends CollectionSource {
} }
void _reportCollectionDimensions() { void _reportCollectionDimensions() {
if (!settings.isCrashlyticsEnabled) return;
final analytics = FirebaseAnalytics(); final analytics = FirebaseAnalytics();
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()); analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()); analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
@ -142,9 +142,9 @@ class MediaStoreSource extends CollectionSource {
}).where((kv) => kv != null)); }).where((kv) => kv != null));
// clean up obsolete entries // clean up obsolete entries
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
removeEntries(obsoleteUris); await removeEntries(obsoleteUris);
obsoleteContentIds.forEach(uriByContentId.remove); obsoleteContentIds.forEach(uriByContentId.remove);
// fetch new entries // fetch new entries
@ -154,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
for (final kv in uriByContentId.entries) { for (final kv in uriByContentId.entries) {
final contentId = kv.key; final contentId = kv.key;
final uri = kv.value; final uri = kv.value;
final sourceEntry = await ImageFileService.getEntry(uri, null); final sourceEntry = await imageFileService.getEntry(uri, null);
if (sourceEntry != null) { if (sourceEntry != null) {
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
// compare paths because some apps move files without updating their `last modified date` // compare paths because some apps move files without updating their `last modified date`
@ -189,7 +189,7 @@ class MediaStoreSource extends CollectionSource {
@override @override
Future<void> refreshMetadata(Set<AvesEntry> entries) { Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet(); final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false); metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh(); return refresh();
} }
} }

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View file

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -30,7 +30,7 @@ class AppShortcutService {
Uint8List iconBytes; Uint8List iconBytes;
if (entry != null) { if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0; final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail( iconBytes = await imageFileService.getThumbnail(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
pageId: entry.pageId, pageId: entry.pageId,

View file

@ -11,7 +11,82 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
class ImageFileService { abstract class ImageFileService {
Future<AvesEntry> getEntry(String uri, String mimeType);
Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
});
Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped, {
int pageId,
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
});
// `rect`: region to decode, with coordinates in reference to `imageSize`
Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped,
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
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,
});
Future<void> clearSizedThumbnailDiskCache();
bool cancelRegion(Object taskKey);
bool cancelThumbnail(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,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
});
Future<Map> rename(AvesEntry entry, String newName);
Future<Map> rotate(AvesEntry entry, {@required bool clockwise});
Future<Map> flip(AvesEntry entry);
}
class PlatformImageFileService implements ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image'); static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
@ -31,7 +106,8 @@ class ImageFileService {
}; };
} }
static Future<AvesEntry> getEntry(String uri, String mimeType) async { @override
Future<AvesEntry> getEntry(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{ final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -44,7 +120,8 @@ class ImageFileService {
return null; return null;
} }
static Future<Uint8List> getSvg( @override
Future<Uint8List> getSvg(
String uri, String uri,
String mimeType, { String mimeType, {
int expectedContentLength, int expectedContentLength,
@ -59,7 +136,8 @@ class ImageFileService {
onBytesReceived: onBytesReceived, onBytesReceived: onBytesReceived,
); );
static Future<Uint8List> getImage( @override
Future<Uint8List> getImage(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -106,8 +184,8 @@ class ImageFileService {
return Future.sync(() => null); return Future.sync(() => null);
} }
// `rect`: region to decode, with coordinates in reference to `imageSize` @override
static Future<Uint8List> getRegion( Future<Uint8List> getRegion(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -145,7 +223,8 @@ class ImageFileService {
); );
} }
static Future<Uint8List> getThumbnail({ @override
Future<Uint8List> getThumbnail({
@required String uri, @required String uri,
@required String mimeType, @required String mimeType,
@required int rotationDegrees, @required int rotationDegrees,
@ -184,7 +263,8 @@ class ImageFileService {
); );
} }
static Future<void> clearSizedThumbnailDiskCache() async { @override
Future<void> clearSizedThumbnailDiskCache() async {
try { try {
return platform.invokeMethod('clearSizedThumbnailDiskCache'); return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -192,13 +272,17 @@ class ImageFileService {
} }
} }
static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); @override
bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]);
static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); @override
bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey); @override
Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) { @override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', 'op': 'delete',
@ -210,7 +294,8 @@ class ImageFileService {
} }
} }
static Stream<MoveOpEvent> move( @override
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required bool copy, @required bool copy,
@required String destinationAlbum, @required String destinationAlbum,
@ -228,7 +313,8 @@ class ImageFileService {
} }
} }
static Stream<ExportOpEvent> export( @override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg, String mimeType = MimeTypes.jpeg,
@required String destinationAlbum, @required String destinationAlbum,
@ -246,7 +332,8 @@ class ImageFileService {
} }
} }
static Future<Map> rename(AvesEntry entry, String newName) async { @override
Future<Map> rename(AvesEntry entry, String newName) async {
try { try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional) // returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{ final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -260,7 +347,8 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async { @override
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -274,7 +362,8 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> flip(AvesEntry entry) async { @override
Future<Map> flip(AvesEntry entry) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{ final result = await platform.invokeMethod('flip', <String, dynamic>{

View file

@ -5,11 +5,21 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
class MediaStoreService { abstract class MediaStoreService {
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds);
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById);
// knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
}
class PlatformMediaStoreService implements MediaStoreService {
static const platform = MethodChannel('deckers.thibault/aves/mediastore'); static const platform = MethodChannel('deckers.thibault/aves/mediastore');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async { @override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
try { try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{ final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds, 'knownContentIds': knownContentIds,
@ -21,7 +31,8 @@ class MediaStoreService {
return []; return [];
} }
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async { @override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
try { try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{ final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById, 'knownPathById': knownPathById,
@ -33,8 +44,8 @@ class MediaStoreService {
return []; return [];
} }
// knownEntries: map of contentId -> dateModifiedSecs @override
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) { Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try { try {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{ return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries, 'knownEntries': knownEntries,

View file

@ -8,11 +8,32 @@ import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class MetadataService { 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<OverlayMetadata> getOverlayMetadata(AvesEntry entry);
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry);
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getEmbeddedPictures(String uri);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformMetadataService implements MetadataService {
static const platform = MethodChannel('deckers.thibault/aves/metadata'); static const platform = MethodChannel('deckers.thibault/aves/metadata');
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description) @override
static Future<Map> getAllMetadata(AvesEntry entry) async { Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -28,7 +49,8 @@ class MetadataService {
return {}; return {};
} }
static Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async { @override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
Future<CatalogMetadata> call() async { Future<CatalogMetadata> call() async {
@ -65,7 +87,8 @@ class MetadataService {
: call(); : call();
} }
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async { @override
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -82,7 +105,8 @@ class MetadataService {
return null; return null;
} }
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async { @override
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{ final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -96,7 +120,8 @@ class MetadataService {
return null; return null;
} }
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async { @override
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
try { try {
// returns map with values for: // returns map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
@ -113,7 +138,8 @@ class MetadataService {
return null; return null;
} }
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async { @override
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
try { try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{ return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -126,7 +152,8 @@ class MetadataService {
return null; return null;
} }
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async { @override
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -138,7 +165,8 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async { @override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{ final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -152,7 +180,8 @@ class MetadataService {
return []; return [];
} }
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { @override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try { try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{ final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,

View file

@ -0,0 +1,27 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
final timeService = getIt<TimeService>();
void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
}

View file

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/string_utils.dart'; import 'package:aves/utils/string_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,7 +17,7 @@ class SvgMetadataService {
static Future<Size> getSize(AvesEntry entry) async { static Future<Size> getSize(AvesEntry entry) async {
try { try {
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType); final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data)); final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;
@ -59,7 +59,7 @@ class SvgMetadataService {
} }
try { try {
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType); final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data)); final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;

View file

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

View file

@ -48,6 +48,7 @@ class AIcons {
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData search = Icons.search_outlined; static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined; static const IconData select = Icons.select_all_outlined;
static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined; static const IconData share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined; static const IconData sort = Icons.sort_outlined;
static const IconData stats = Icons.pie_chart_outlined; static const IconData stats = Icons.pie_chart_outlined;

51
lib/theme/themes.dart Normal file
View file

@ -0,0 +1,51 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class Themes {
static const _accentColor = Colors.indigoAccent;
static final darkTheme = ThemeData(
brightness: Brightness.dark,
accentColor: _accentColor,
scaffoldBackgroundColor: Colors.grey[900],
buttonColor: _accentColor,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: _accentColor,
tooltipTheme: TooltipThemeData(
verticalOffset: 32,
),
appBarTheme: AppBarTheme(
textTheme: TextTheme(
headline6: TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
color: Colors.white,
),
behavior: SnackBarBehavior.floating,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: _accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: _accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,
),
),
);
}

View file

@ -159,6 +159,12 @@ class Constants {
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE', licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
sourceUrl: 'https://github.com/marcojakob/dart-event-bus', sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
), ),
Dependency(
name: 'Get It',
license: 'MIT',
licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency( Dependency(
name: 'Github', name: 'Github',
license: 'MIT', license: 'MIT',

View file

@ -1,4 +1,4 @@
import 'package:aves/model/availability.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/about/news_badge.dart'; import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -96,24 +96,29 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return ValueListenableBuilder<Activity>( return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier, valueListenable: collection.activityNotifier,
builder: (context, activity, child) { builder: (context, activity, child) {
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
builder: (context, child) => SliverAppBar( builder: (context, child) {
leading: _buildAppBarLeading(), final removableFilters = appMode != AppMode.pickInternal;
title: _buildAppBarTitle(), return SliverAppBar(
actions: _buildActions(), leading: appMode.hasDrawer ? _buildAppBarLeading() : null,
bottom: hasFilters title: _buildAppBarTitle(),
? FilterBar( actions: _buildActions(),
filters: collection.filters, bottom: hasFilters
onPressed: collection.removeFilter, ? FilterBar(
) filters: collection.filters,
: null, removable: removableFilters,
titleSpacing: 0, onTap: removableFilters ? collection.removeFilter : null,
floating: true, )
), : null,
titleSpacing: 0,
floating: true,
);
},
); );
}, },
); );
@ -143,7 +148,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarTitle() { Widget _buildAppBarTitle() {
if (collection.isBrowsing) { if (collection.isBrowsing) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode == AppMode.pick ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
if (appMode == AppMode.main) { if (appMode == AppMode.main) {
title = SourceStateAwareAppBarTitle( title = SourceStateAwareAppBarTitle(
title: title, title: title,
@ -151,7 +156,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
return InteractiveAppBarTitle( return InteractiveAppBarTitle(
onTap: _goToSearch, onTap: appMode.canSearch ? _goToSearch : null,
child: title, child: title,
); );
} else if (collection.isSelecting) { } else if (collection.isSelecting) {
@ -167,8 +172,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
List<Widget> _buildActions() { List<Widget> _buildActions() {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return [ return [
if (collection.isBrowsing) if (collection.isBrowsing && appMode.canSearch)
CollectionSearchButton( CollectionSearchButton(
source, source,
parentCollection: collection, parentCollection: collection,
@ -193,7 +199,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
itemBuilder: (context) { itemBuilder: (context) {
final isNotEmpty = !collection.isEmpty; final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty; final hasSelection = collection.selection.isNotEmpty;
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
return [ return [
PopupMenuItem( PopupMenuItem(
key: Key('menu-sort'), key: Key('menu-sort'),
@ -206,19 +211,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.group, value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
), ),
if (collection.isBrowsing) ...[ if (collection.isBrowsing && appMode == AppMode.main) ...[
if (isMainMode) PopupMenuItem(
PopupMenuItem( value: CollectionAction.select,
value: CollectionAction.select, enabled: isNotEmpty,
enabled: isNotEmpty, child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select), ),
),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.stats, value: CollectionAction.stats,
enabled: isNotEmpty, enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats), child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
), ),
if (isMainMode && canAddShortcuts) if (canAddShortcuts)
PopupMenuItem( PopupMenuItem(
value: CollectionAction.addShortcut, value: CollectionAction.addShortcut,
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut), child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
@ -34,6 +34,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CollectionGrid extends StatefulWidget { class CollectionGrid extends StatefulWidget {
final String settingsRouteKey;
const CollectionGrid({
this.settingsRouteKey,
});
@override @override
_CollectionGridState createState() => _CollectionGridState(); _CollectionGridState createState() => _CollectionGridState();
} }
@ -44,7 +50,7 @@ class _CollectionGridState extends State<CollectionGrid> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController( _tileExtentController ??= TileExtentController(
settingsRouteKey: context.currentRouteName, settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName,
columnCountDefault: 4, columnCountDefault: 4,
extentMin: 46, extentMin: 46,
spacing: 0, spacing: 0,

View file

@ -8,8 +8,8 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -99,19 +99,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final copy = moveType == MoveType.copy; final copy = moveType == MoveType.copy;
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), opStream: imageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final movedOps = processed.where((e) => e.success).toSet(); final movedOps = processed.where((e) => e.success).toSet();
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: todoEntries, todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: copy, copy: copy,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
movedOps: movedOps, movedOps: movedOps,
@ -119,13 +115,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
collection.browse(); collection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
final l10n = context.l10n;
final movedCount = movedOps.length; final movedCount = movedOps.length;
if (movedCount < todoCount) { if (movedCount < todoCount) {
final count = todoCount - movedCount; final count = todoCount - movedCount;
showFeedback(context, copy ? context.l10n.collectionCopyFailureFeedback(count) : context.l10n.collectionMoveFailureFeedback(count)); showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else { } else {
final count = movedCount; final count = movedCount;
showFeedback(context, copy ? context.l10n.collectionCopySuccessFeedback(count) : context.l10n.collectionMoveSuccessFeedback(count)); showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count));
} }
}, },
); );
@ -161,11 +158,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: ImageFileService.delete(selection), opStream: imageFileService.delete(selection),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) { onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
source.removeEntries(deletedUris); await source.removeEntries(deletedUris);
collection.browse(); collection.browse();
source.resumeMonitoring(); source.resumeMonitoring();

View file

@ -8,12 +8,14 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
final List<CollectionFilter> filters; final List<CollectionFilter> filters;
final FilterCallback onPressed; final bool removable;
final FilterCallback onTap;
FilterBar({ FilterBar({
Key key, Key key,
@required Set<CollectionFilter> filters, @required Set<CollectionFilter> filters,
@required this.onPressed, @required this.removable,
this.onTap,
}) : filters = List<CollectionFilter>.from(filters)..sort(), }) : filters = List<CollectionFilter>.from(filters)..sort(),
super(key: key); super(key: key);
@ -26,7 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
class _FilterBarState extends State<FilterBar> { class _FilterBarState extends State<FilterBar> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list'); final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
CollectionFilter _userRemovedFilter; CollectionFilter _userTappedFilter;
FilterCallback get onTap => widget.onTap;
@override @override
void didUpdateWidget(covariant FilterBar oldWidget) { void didUpdateWidget(covariant FilterBar oldWidget) {
@ -41,7 +45,7 @@ class _FilterBarState extends State<FilterBar> {
existing.removeAt(index); existing.removeAt(index);
// only animate item removal when triggered by a user interaction with the chip, // only animate item removal when triggered by a user interaction with the chip,
// not from automatic chip replacement following chip selection // not from automatic chip replacement following chip selection
final animate = _userRemovedFilter == filter; final animate = _userTappedFilter == filter;
listState.removeItem( listState.removeItem(
index, index,
animate animate
@ -70,7 +74,7 @@ class _FilterBarState extends State<FilterBar> {
duration: Duration.zero, duration: Duration.zero,
); );
}); });
_userRemovedFilter = null; _userTappedFilter = null;
} }
@override @override
@ -106,12 +110,14 @@ class _FilterBarState extends State<FilterBar> {
child: AvesFilterChip( child: AvesFilterChip(
key: ValueKey(filter), key: ValueKey(filter),
filter: filter, filter: filter,
removable: true, removable: widget.removable,
heroType: HeroType.always, heroType: HeroType.always,
onTap: (filter) { onTap: onTap != null
_userRemovedFilter = filter; ? (filter) {
widget.onPressed(filter); _userTappedFilter = filter;
}, onTap(filter);
}
: null,
), ),
), ),
); );

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
@ -29,14 +29,22 @@ class InteractiveThumbnail extends StatelessWidget {
key: ValueKey(entry.uri), key: ValueKey(entry.uri),
onTap: () { onTap: () {
final appMode = context.read<ValueNotifier<AppMode>>().value; final appMode = context.read<ValueNotifier<AppMode>>().value;
if (appMode == AppMode.main) { switch (appMode) {
if (collection.isBrowsing) { case AppMode.main:
_goToViewer(context); if (collection.isBrowsing) {
} else if (collection.isSelecting) { _goToViewer(context);
collection.toggleSelection(entry); } else if (collection.isSelecting) {
} collection.toggleSelection(entry);
} else if (appMode == AppMode.pick) { }
ViewerService.pick(entry.uri); break;
case AppMode.pickExternal:
ViewerService.pick(entry.uri);
break;
case AppMode.pickInternal:
Navigator.pop(context, entry);
break;
case AppMode.view:
break;
} }
}, },
child: MetaData( child: MetaData(

View file

@ -7,7 +7,12 @@ mixin FeedbackMixin {
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar(); void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
void showFeedback(BuildContext context, String message) { void showFeedback(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showFeedbackWithMessenger(ScaffoldMessenger.of(context), message);
}
// provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message) {
messenger.showSnackBar(SnackBar(
content: Text(message), content: Text(message),
duration: Durations.opToastDisplay, duration: Durations.opToastDisplay,
)); ));

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -24,11 +24,11 @@ class AvesFilterChip extends StatefulWidget {
final bool showGenericIcon; final bool showGenericIcon;
final Widget background; final Widget background;
final Widget details; final Widget details;
final BorderRadius borderRadius;
final double padding; final double padding;
final HeroType heroType; final HeroType heroType;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback onLongPress; final OffsetFilterCallback onLongPress;
final BorderRadius borderRadius;
static const Color defaultOutlineColor = Colors.white; static const Color defaultOutlineColor = Colors.white;
static const double defaultRadius = 32; static const double defaultRadius = 32;
@ -100,6 +100,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
double get padding => widget.padding; double get padding => widget.padding;
FilterCallback get onTap => widget.onTap;
OffsetFilterCallback get onLongPress => widget.onLongPress;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -218,14 +222,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
child: InkWell( child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead // so we get the long press details from the tap instead
onTapDown: (details) => _tapPosition = details.globalPosition, onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: widget.onTap != null onTap: onTap != null
? () { ? () {
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter)); WidgetsBinding.instance.addPostFrameCallback((_) => onTap(filter));
setState(() => _tapped = true); setState(() => _tapped = true);
} }
: null, : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null, onLongPress: onLongPress != null ? () => onLongPress(context, filter, _tapPosition) : null,
borderRadius: borderRadius, borderRadius: borderRadius,
child: FutureBuilder<Color>( child: FutureBuilder<Color>(
future: _colorFuture, future: _colorFuture,

View file

@ -1,4 +1,4 @@
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -60,7 +60,7 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
), ),
SizedBox(width: 8), SizedBox(width: 8),
ElevatedButton( ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache, onPressed: imageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'), child: Text('Clear'),
), ),
], ],

View file

@ -1,7 +1,8 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,7 +18,8 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
Future<List<DateMetadata>> _dbDateLoader; Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader; Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader; Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader; Future<Set<FavouriteRow>> _dbFavouritesLoader;
Future<Set<CoverRow>> _dbCoversLoader;
@override @override
void initState() { void initState() {
@ -141,7 +143,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
); );
}, },
), ),
FutureBuilder<List>( FutureBuilder<Set>(
future: _dbFavouritesLoader, future: _dbFavouritesLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
@ -162,6 +164,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
); );
}, },
), ),
FutureBuilder<Set>(
future: _dbCoversLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('cover rows: ${snapshot.data.length} (${covers.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => covers.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
], ],
), ),
), ),
@ -176,6 +199,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
_dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses(); _dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites(); _dbFavouritesLoader = metadataDb.loadFavourites();
_dbCoversLoader = metadataDb.loadCovers();
setState(() {}); setState(() {});
} }

View file

@ -0,0 +1,133 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class CoverSelectionDialog extends StatefulWidget {
final CollectionFilter filter;
final AvesEntry customEntry;
const CoverSelectionDialog({
@required this.filter,
@required this.customEntry,
});
@override
_CoverSelectionDialogState createState() => _CoverSelectionDialogState();
}
class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
bool _isCustom;
AvesEntry _customEntry;
CollectionFilter get filter => widget.filter;
@override
void initState() {
super.initState();
_customEntry = widget.customEntry;
_isCustom = _customEntry != null;
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Builder(
builder: (context) {
final l10n = context.l10n;
final shortestSide = context.select<MediaQueryData, double>((mq) => mq.size.shortestSide);
final extent = (shortestSide / 3.0).clamp(60.0, 160.0);
return AvesDialog(
context: context,
title: l10n.setCoverDialogTitle,
scrollableContent: [
...[false, true].map(
(isCustom) {
final title = Text(
isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogLatest,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
return RadioListTile(
value: isCustom,
groupValue: _isCustom,
onChanged: (v) {
if (v && _customEntry == null) {
_pickEntry();
return;
}
_isCustom = v;
setState(() {});
},
title: isCustom
? Row(children: [
title,
Spacer(),
IconButton(
onPressed: _isCustom ? _pickEntry : null,
tooltip: 'Change',
icon: Icon(AIcons.setCover),
),
])
: title,
);
},
),
Container(
alignment: Alignment.center,
padding: EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip(
filter: filter,
extent: extent,
coverEntry: _isCustom ? _customEntry : null,
onTap: (filter) => _pickEntry(),
),
),
],
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, Tuple2<bool, AvesEntry>(_isCustom, _customEntry)),
child: Text(l10n.applyButtonLabel),
),
],
);
},
),
);
}
Future<void> _pickEntry() async {
final entry = await Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: ItemPickDialog.routeName),
builder: (context) => ItemPickDialog(
CollectionLens(
source: context.read<CollectionSource>(),
filters: [filter],
),
),
fullscreenDialog: true,
),
);
if (entry != null) {
_customEntry = entry;
_isCustom = true;
setState(() {});
}
}
}

View file

@ -0,0 +1,51 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ItemPickDialog extends StatefulWidget {
static const routeName = '/item_pick';
final CollectionLens collection;
const ItemPickDialog(this.collection);
@override
_ItemPickDialogState createState() => _ItemPickDialogState();
}
class _ItemPickDialogState extends State<ItemPickDialog> {
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableProvider<ValueNotifier<AppMode>>.value(
value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider(
child: Scaffold(
body: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: CollectionGrid(
settingsRouteKey: CollectionPage.routeName,
),
),
),
),
),
),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/availability.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
@ -8,6 +7,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/about/about_page.dart';

View file

@ -40,6 +40,7 @@ class AlbumListPage extends StatelessWidget {
chipActionDelegate: AlbumChipActionDelegate(), chipActionDelegate: AlbumChipActionDelegate(),
chipActionsBuilder: (filter) => [ chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.rename, ChipAction.rename,
ChipAction.delete, ChipAction.delete,
ChipAction.hide, ChipAction.hide,

View file

@ -1,19 +1,22 @@
import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart';
@ -21,6 +24,7 @@ import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate { class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
@ -34,6 +38,9 @@ class ChipActionDelegate {
case ChipAction.hide: case ChipAction.hide:
_hide(context, filter); _hide(context, filter);
break; break;
case ChipAction.setCover:
_showCoverSelectionDialog(context, filter);
break;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage()); _goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage());
break; break;
@ -74,6 +81,22 @@ class ChipActionDelegate {
source.changeFilterVisibility(filter, false); source.changeFilterVisibility(filter, false);
} }
void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
void _goTo( void _goTo(
BuildContext context, BuildContext context,
CollectionFilter filter, CollectionFilter filter,
@ -140,11 +163,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: ImageFileService.delete(selection), opStream: imageFileService.delete(selection),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) { onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
source.removeEntries(deletedUris); await source.removeEntries(deletedUris);
source.resumeMonitoring(); source.resumeMonitoring();
final deletedCount = deletedUris.length; final deletedCount = deletedUris.length;
@ -182,38 +205,26 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), opStream: imageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final movedOps = processed.where((e) => e.success).toSet(); final movedOps = processed.where((e) => e.success).toSet();
final pinned = settings.pinnedFilters.contains(filter); await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps);
await source.updateAfterMove(
todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// repin new album after obsolete album got removed and unpinned
if (pinned) {
final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(context, destinationAlbum));
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
source.resumeMonitoring(); source.resumeMonitoring();
final movedCount = movedOps.length; final movedCount = movedOps.length;
if (movedCount < todoCount) { if (movedCount < todoCount) {
final count = todoCount - movedCount; final count = todoCount - movedCount;
showFeedback(context, context.l10n.collectionMoveFailureFeedback(count)); showFeedbackWithMessenger(messenger, l10n.collectionMoveFailureFeedback(count));
} else { } else {
showFeedback(context, context.l10n.genericSuccessFeedback); showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback);
} }
}, },
); );

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
@ -25,6 +26,7 @@ import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget { class DecoratedFilterChip extends StatelessWidget {
final CollectionFilter filter; final CollectionFilter filter;
final double extent; final double extent;
final AvesEntry coverEntry;
final bool pinned, highlightable; final bool pinned, highlightable;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback onLongPress; final OffsetFilterCallback onLongPress;
@ -33,6 +35,7 @@ class DecoratedFilterChip extends StatelessWidget {
Key key, Key key,
@required this.filter, @required this.filter,
@required this.extent, @required this.extent,
this.coverEntry,
this.pinned = false, this.pinned = false,
this.highlightable = true, this.highlightable = true,
this.onTap, this.onTap,
@ -76,7 +79,7 @@ class DecoratedFilterChip extends StatelessWidget {
} }
Widget _buildChip(CollectionSource source) { Widget _buildChip(CollectionSource source) {
final entry = source.recentEntry(filter); final entry = coverEntry ?? source.coverEntry(filter);
final backgroundImage = entry == null final backgroundImage = entry == null
? Container(color: Colors.white) ? Container(color: Colors.white)
: entry.isSvg : entry.isSvg
@ -89,7 +92,7 @@ class DecoratedFilterChip extends StatelessWidget {
extent: extent, extent: extent,
); );
final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4); final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4);
final titlePadding = min<double>(6.0, extent / 16); final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(Radius.circular(radius)); final borderRadius = BorderRadius.all(Radius.circular(radius));
Widget child = AvesFilterChip( Widget child = AvesFilterChip(
filter: filter, filter: filter,

View file

@ -1,5 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -64,17 +65,20 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
child: GestureAreaProtectorStack( child: GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: FilterGrid<T>( child: AnimatedBuilder(
settingsRouteKey: settingsRouteKey, animation: covers,
appBar: appBar, builder: (context, child) => FilterGrid<T>(
appBarHeight: appBarHeight, settingsRouteKey: settingsRouteKey,
filterSections: filterSections, appBar: appBar,
showHeaders: showHeaders, appBarHeight: appBarHeight,
queryNotifier: queryNotifier, filterSections: filterSections,
applyQuery: applyQuery, showHeaders: showHeaders,
emptyBuilder: emptyBuilder, queryNotifier: queryNotifier,
onTap: onTap, applyQuery: applyQuery,
onLongPress: onLongPress, emptyBuilder: emptyBuilder,
onTap: onTap,
onLongPress: onLongPress,
),
), ),
), ),
), ),

View file

@ -1,6 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';

View file

@ -35,6 +35,7 @@ class CountryListPage extends StatelessWidget {
chipActionDelegate: ChipActionDelegate(), chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [ chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide, ChipAction.hide,
], ],
filterSections: _getCountryEntries(source), filterSections: _getCountryEntries(source),

View file

@ -35,6 +35,7 @@ class TagListPage extends StatelessWidget {
chipActionDelegate: ChipActionDelegate(), chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [ chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide, ChipAction.hide,
], ],
filterSections: _getTagEntries(source), filterSections: _getTagEntries(source),

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
@ -6,7 +6,7 @@ import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
@ -81,7 +81,7 @@ class _HomePageState extends State<HomePage> {
} }
break; break;
case 'pick': case 'pick':
appMode = AppMode.pick; appMode = AppMode.pickExternal;
// TODO TLAD apply pick mimetype(s) // TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String pickMimeTypes = intentData['mimeType']; String pickMimeTypes = intentData['mimeType'];
@ -110,7 +110,7 @@ class _HomePageState extends State<HomePage> {
} }
Future<AvesEntry> _initViewerEntry({@required String uri, @required String mimeType}) async { Future<AvesEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
final entry = await ImageFileService.getEntry(uri, mimeType); final entry = await imageFileService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation // cataloguing is essential for coordinates and video rotation
await entry.catalog(); await entry.catalog();
@ -130,7 +130,7 @@ class _HomePageState extends State<HomePage> {
String routeName; String routeName;
Iterable<CollectionFilter> filters; Iterable<CollectionFilter> filters;
if (appMode == AppMode.pick) { if (appMode == AppMode.pickExternal) {
routeName = CollectionPage.routeName; routeName = CollectionPage.routeName;
} else { } else {
routeName = _shortcutRouteName ?? settings.homePage.routeName; routeName = _shortcutRouteName ?? settings.homePage.routeName;

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/services.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/app_mode.dart';
import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/main.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';

View file

@ -6,9 +6,8 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -141,7 +140,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
showFeedback(context, context.l10n.genericFailureFeedback); showFeedback(context, context.l10n.genericFailureFeedback);
} else { } else {
if (hasCollection) { if (hasCollection) {
collection.source.removeEntries({entry.uri}); await collection.source.removeEntries({entry.uri});
} }
EntryDeletedNotification(entry).dispatch(context); EntryDeletedNotification(entry).dispatch(context);
} }
@ -170,7 +169,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultipage) { if (entry.isMultipage) {
final multiPageInfo = await MetadataService.getMultiPageInfo(entry); final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) { for (final page in multiPageInfo.pages) {
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false); final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
@ -184,7 +183,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length; final selectionCount = selection.length;
showOpReport<ExportOpEvent>( showOpReport<ExportOpEvent>(
context: context, context: context,
opStream: ImageFileService.export(selection, destinationAlbum: destinationAlbum), opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) { onDone: (processed) {
final movedOps = processed.where((e) => e.success); final movedOps = processed.where((e) => e.success);
@ -208,7 +207,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {entry})) return;
if (await entry.rename(newName)) { final success = await context.read<CollectionSource>().renameEntry(entry, newName);
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback); showFeedback(context, context.l10n.genericSuccessFeedback);
} else { } else {
showFeedback(context, context.l10n.genericFailureFeedback); showFeedback(context, context.l10n.genericFailureFeedback);
@ -221,7 +222,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName), settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage( builder: (context) => SourceViewerPage(
loader: () => ImageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode), loader: () => imageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
), ),
), ),
); );

View file

@ -1,11 +1,11 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/window_service.dart'; import 'package:aves/services/window_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
@ -8,7 +8,7 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -87,7 +87,7 @@ class BasicSection extends StatelessWidget {
...tags.map((tag) => TagFilter(tag)), ...tags.map((tag) => TagFilter(tag)),
}; };
return AnimatedBuilder( return AnimatedBuilder(
animation: favourites.changeNotifier, animation: favourites,
builder: (context, child) { builder: (context, child) {
final effectiveFilters = [ final effectiveFilters = [
...filters, ...filters,
@ -188,20 +188,21 @@ class _OwnerPropState extends State<OwnerProp> {
), ),
// `com.android.shell` is the package reported // `com.android.shell` is the package reported
// for images copied to the device by ADB for Test Driver // for images copied to the device by ADB for Test Driver
if (_ownerPackage != 'com.android.shell') WidgetSpan( if (_ownerPackage != 'com.android.shell')
alignment: PlaceholderAlignment.middle, WidgetSpan(
child: Padding( alignment: PlaceholderAlignment.middle,
padding: EdgeInsets.symmetric(horizontal: 4), child: Padding(
child: Image( padding: EdgeInsets.symmetric(horizontal: 4),
image: AppIconImage( child: Image(
packageName: _ownerPackage, image: AppIconImage(
size: iconSize, packageName: _ownerPackage,
size: iconSize,
),
width: iconSize,
height: iconSize,
), ),
width: iconSize,
height: iconSize,
), ),
), ),
),
TextSpan( TextSpan(
text: appName, text: appName,
style: InfoRowGroup.baseStyle, style: InfoRowGroup.baseStyle,
@ -217,7 +218,7 @@ class _OwnerPropState extends State<OwnerProp> {
if (entry == null) return; if (entry == null) return;
if (_loadedUri.value == entry.uri) return; if (_loadedUri.value == entry.uri) return;
if (isVisible) { if (isVisible) {
_ownerPackage = await MetadataService.getContentResolverProp(widget.entry, 'owner_package_name'); _ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
_loadedUri.value = entry.uri; _loadedUri.value = entry.uri;
} else { } else {
_ownerPackage = null; _ownerPackage = null;

View file

@ -1,10 +1,10 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -1,8 +1,8 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -1,7 +1,7 @@
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -138,7 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (entry == null) return; if (entry == null) return;
if (_loadedMetadataUri.value == entry.uri) return; if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) { if (isVisible) {
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)) ?? {};
final directories = rawMetadata.entries.map((dirKV) { final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? ''; var directoryName = dirKV.key as String ?? '';

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -34,10 +34,10 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
super.initState(); super.initState();
switch (widget.source) { switch (widget.source) {
case MetadataThumbnailSource.embedded: case MetadataThumbnailSource.embedded:
_loader = MetadataService.getEmbeddedPictures(uri); _loader = metadataService.getEmbeddedPictures(uri);
break; break;
case MetadataThumbnailSource.exif: case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(entry); _loader = metadataService.getExifThumbnails(entry);
break; break;
} }
} }

View file

@ -4,7 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/xmp.dart'; import 'package:aves/ref/xmp.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
@ -105,7 +105,7 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
} }
Future<void> _openEmbeddedData(String propPath, String propMimeType) async { Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType); final fields = await metadataService.extractXmpDataProp(entry, propPath, propMimeType);
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback); showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
return; return;

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -11,7 +11,7 @@ class MultiPageController extends ChangeNotifier {
final ValueNotifier<int> pageNotifier = ValueNotifier(null); final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageController(AvesEntry entry) { MultiPageController(AvesEntry entry) {
info = MetadataService.getMultiPageInfo(entry).then((value) { info = metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index; pageNotifier.value = value.defaultPage.index;
return value; return value;
}); });

View file

@ -5,7 +5,7 @@ import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
@ -69,7 +69,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
} }
void _initDetailLoader() { void _initDetailLoader() {
_detailLoader = MetadataService.getOverlayMetadata(entry); _detailLoader = metadataService.getOverlayMetadata(entry);
} }
@override @override

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/panorama_page.dart'; import 'package:aves/widgets/viewer/panorama_page.dart';
@ -25,7 +25,7 @@ class PanoramaOverlay extends StatelessWidget {
scale: scale, scale: scale,
buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel, buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel,
onPressed: () async { onPressed: () async {
final info = await MetadataService.getPanoramaInfo(entry); final info = await metadataService.getPanoramaInfo(entry);
if (info != null) { if (info != null) {
unawaited(Navigator.push( unawaited(Navigator.push(
context, context,

View file

@ -2,7 +2,7 @@ import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -323,7 +323,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
favourites.changeNotifier.addListener(_onChanged); favourites.addListener(_onChanged);
_onChanged(); _onChanged();
} }
@ -335,7 +335,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
@override @override
void dispose() { void dispose() {
favourites.changeNotifier.removeListener(_onChanged); favourites.removeListener(_onChanged);
super.dispose(); super.dispose();
} }

View file

@ -3,8 +3,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -49,7 +48,7 @@ class EntryPrinter with FeedbackMixin {
} }
if (entry.isMultipage) { if (entry.isMultipage) {
final multiPageInfo = await MetadataService.getMultiPageInfo(entry); final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
final streamController = StreamController<AvesEntry>.broadcast(); final streamController = StreamController<AvesEntry>.broadcast();
showOpReport<AvesEntry>( showOpReport<AvesEntry>(
@ -73,7 +72,7 @@ class EntryPrinter with FeedbackMixin {
Future<pdf.Widget> _buildPageImage(AvesEntry entry) async { Future<pdf.Widget> _buildPageImage(AvesEntry entry) async {
if (entry.isSvg) { if (entry.isSvg) {
final bytes = await ImageFileService.getSvg(entry.uri, entry.mimeType); final bytes = await imageFileService.getSvg(entry.uri, entry.mimeType);
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
return pdf.SvgImage(svg: utf8.decode(bytes)); return pdf.SvgImage(svg: utf8.decode(bytes));
} }

View file

@ -371,6 +371,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
get_it:
dependency: "direct main"
description:
name: get_it
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.0"
github: github:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -49,6 +49,7 @@ dependencies:
flutter_markdown: flutter_markdown:
flutter_staggered_animations: flutter_staggered_animations:
flutter_svg: flutter_svg:
get_it:
github: github:
google_api_availability: google_api_availability:
google_maps_flutter: google_maps_flutter:

View file

@ -0,0 +1,8 @@
import 'package:aves/model/availability.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeAvesAvailability extends Fake implements AvesAvailability {
@override
Future<bool> get canLocatePlaces => SynchronousFuture(false);
}

View file

@ -0,0 +1,21 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'media_store_service.dart';
class FakeImageFileService extends Fake implements ImageFileService {
@override
Future<Map> rename(AvesEntry entry, String newName) {
final contentId = FakeMediaStoreService.nextContentId;
return SynchronousFuture({
'uri': 'content://media/external/images/media/$contentId',
'contentId': contentId,
'path': '${entry.directory}/$newName',
'displayName': newName,
'title': newName.substring(0, newName.length - entry.extension.length),
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
});
}
}

View file

@ -0,0 +1,62 @@
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/media_store_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMediaStoreService extends Fake implements MediaStoreService {
Set<AvesEntry> entries = {};
@override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) => SynchronousFuture([]);
@override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) => SynchronousFuture([]);
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) => Stream.fromIterable(entries);
static var _lastContentId = 1;
static int get nextContentId => _lastContentId++;
static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000;
static AvesEntry newImage(String album, String filenameWithoutExtension) {
final contentId = nextContentId;
final date = dateSecs;
return AvesEntry(
uri: 'content://media/external/images/media/$contentId',
contentId: contentId,
path: '$album/$filenameWithoutExtension.jpg',
pageId: null,
sourceMimeType: MimeTypes.jpeg,
width: 360,
height: 720,
sourceRotationDegrees: 0,
sizeBytes: 42,
sourceTitle: filenameWithoutExtension,
dateModifiedSecs: date,
sourceDateTakenMillis: date,
durationMillis: null,
);
}
static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) {
final newContentId = nextContentId;
return MoveOpEvent(
success: true,
uri: entry.uri,
newFields: {
'deletedSource': true,
'uri': 'content://media/external/images/media/$newContentId',
'contentId': newContentId,
'path': entry.path.replaceFirst(sourceAlbum, destinationAlbum),
'displayName': '${entry.filenameWithoutExtension}${entry.extension}',
'title': entry.filenameWithoutExtension,
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
},
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMetadataDb extends Fake implements MetadataDb {
@override
Future<void> init() => null;
@override
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) => null;
@override
Future<Set<AvesEntry>> loadEntries() => SynchronousFuture({});
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) => null;
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) => null;
@override
Future<List<DateMetadata>> loadDates() => SynchronousFuture([]);
@override
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) => null;
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) => null;
@override
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
@override
Future<void> updateAddressId(int oldId, AddressDetails address) => null;
@override
Future<Set<FavouriteRow>> loadFavourites() => SynchronousFuture({});
@override
Future<void> addFavourites(Iterable<FavouriteRow> rows) => null;
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) => null;
@override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => null;
@override
Future<Set<CoverRow>> loadCovers() => SynchronousFuture({});
@override
Future<void> addCovers(Iterable<CoverRow> rows) => null;
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) => null;
@override
Future<void> removeCovers(Iterable<CoverRow> rows) => null;
}

View file

@ -0,0 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMetadataService extends Fake implements MetadataService {
@override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) => null;
}

View file

@ -0,0 +1,8 @@
import 'package:aves/services/time_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeTimeService extends Fake implements TimeService {
@override
Future<String> getDefaultTimeZone() => SynchronousFuture('');
}

View file

@ -0,0 +1,239 @@
import 'dart:async';
import 'package:aves/model/availability.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/time_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake/availability.dart';
import '../fake/image_file_service.dart';
import '../fake/media_store_service.dart';
import '../fake/metadata_db.dart';
import '../fake/metadata_service.dart';
import '../fake/time_service.dart';
void main() {
const volume = '/storage/emulated/0/';
const testAlbum = '${volume}Pictures/test';
const sourceAlbum = '${volume}Pictures/source';
const destinationAlbum = '${volume}Pictures/destination';
setUp(() async {
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
getIt.registerLazySingleton<TimeService>(() => FakeTimeService());
await settings.init();
});
tearDown(() async {
await getIt.reset();
});
Future<MediaStoreSource> _initSource() async {
final source = MediaStoreSource();
final readyCompleter = Completer();
source.stateNotifier.addListener(() {
if (source.stateNotifier.value == SourceState.ready) {
readyCompleter.complete();
}
});
await source.init();
await source.refresh();
await readyCompleter.future;
return source;
}
test('add/remove favourite entry', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
await _initSource();
expect(favourites.count, 0);
await image1.toggleFavourite();
expect(favourites.count, 1);
expect(image1.isFavourite, true);
await image1.toggleFavourite();
expect(favourites.count, 0);
expect(image1.isFavourite, false);
});
test('set/unset entry as album cover', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
expect(source.rawAlbums.length, 1);
expect(covers.count, 0);
final albumFilter = AlbumFilter(testAlbum, 'whatever');
expect(albumFilter.test(image1), true);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
await covers.set(albumFilter, image1.contentId);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
await covers.set(albumFilter, null);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
});
test('favourites and covers are kept when renaming entries', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
final albumFilter = AlbumFilter(testAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.renameEntry(image1, 'image1b.jpg');
expect(favourites.count, 1);
expect(image1.isFavourite, true);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
});
test('favourites and covers are cleared when removing entries', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
final albumFilter = AlbumFilter(image1.directory, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.removeEntries({image1.uri});
expect(source.rawAlbums.length, 0);
expect(favourites.count, 0);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
});
test('albums are updated when moving entries', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
expect(source.rawAlbums.contains(sourceAlbum), true);
expect(source.rawAlbums.contains(destinationAlbum), false);
final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
final destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(sourceAlbumFilter.test(image1), true);
expect(destinationAlbumFilter.test(image1), false);
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(source.rawAlbums.contains(sourceAlbum), false);
expect(source.rawAlbums.contains(destinationAlbum), true);
expect(sourceAlbumFilter.test(image1), false);
expect(destinationAlbumFilter.test(image1), true);
});
test('favourites are kept when moving entries', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(favourites.count, 1);
expect(image1.isFavourite, true);
});
test('album cover is reset when moving cover entry', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
FakeMediaStoreService.newImage(sourceAlbum, 'image2'),
};
final source = await _initSource();
expect(source.rawAlbums.length, 1);
final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(sourceAlbumFilter, image1.contentId);
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(source.rawAlbums.length, 2);
expect(covers.count, 0);
expect(covers.coverContentId(sourceAlbumFilter), null);
});
test('favourites and covers are kept when renaming albums', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
var albumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.renameAlbum(sourceAlbum, destinationAlbum, {
image1
}, {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
});
albumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(favourites.count, 1);
expect(image1.isFavourite, true);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
});
}