#36 set filter cover + service IoC + collection source tests
This commit is contained in:
parent
cf8d182cfe
commit
87f1eb6cc7
76 changed files with 1658 additions and 452 deletions
9
lib/app_mode.dart
Normal file
9
lib/app_mode.dart
Normal 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;
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
import 'dart:math';
|
||||
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/widgets.dart';
|
||||
|
||||
|
@ -32,7 +32,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await ImageFileService.getRegion(
|
||||
final bytes = await imageFileService.getRegion(
|
||||
uri,
|
||||
mimeType,
|
||||
key.rotationDegrees,
|
||||
|
@ -55,11 +55,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
|
||||
ImageFileService.resumeLoading(key);
|
||||
imageFileService.resumeLoading(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
void pause() => ImageFileService.cancelRegion(key);
|
||||
void pause() => imageFileService.cancelRegion(key);
|
||||
}
|
||||
|
||||
class RegionProviderKey {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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/widgets.dart';
|
||||
|
||||
|
@ -33,7 +33,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await ImageFileService.getThumbnail(
|
||||
final bytes = await imageFileService.getThumbnail(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
|
@ -55,11 +55,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||
ImageFileService.resumeLoading(key);
|
||||
imageFileService.resumeLoading(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
void pause() => ImageFileService.cancelThumbnail(key);
|
||||
void pause() => imageFileService.cancelThumbnail(key);
|
||||
}
|
||||
|
||||
class ThumbnailProviderKey {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:async';
|
||||
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/widgets.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
@ -46,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
assert(key == this);
|
||||
|
||||
try {
|
||||
final bytes = await ImageFileService.getImage(
|
||||
final bytes = await imageFileService.getImage(
|
||||
uri,
|
||||
mimeType,
|
||||
rotationDegrees,
|
||||
|
|
|
@ -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/widgets.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 {
|
||||
assert(key == this);
|
||||
|
||||
final data = await ImageFileService.getSvg(uri, mimeType);
|
||||
final data = await imageFileService.getSvg(uri, mimeType);
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -49,6 +49,8 @@
|
|||
"@chipActionUnpin": {},
|
||||
"chipActionRename": "Rename",
|
||||
"@chipActionRename": {},
|
||||
"chipActionSetCover": "Set cover",
|
||||
"@chipActionSetCover": {},
|
||||
|
||||
"entryActionDelete": "Delete",
|
||||
"@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": {},
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
"chipActionPin": "고정",
|
||||
"chipActionUnpin": "고정 해제",
|
||||
"chipActionRename": "이름 변경",
|
||||
"chipActionSetCover": "대표 이미지 변경",
|
||||
|
||||
"entryActionDelete": "삭제",
|
||||
"entryActionExport": "내보내기",
|
||||
|
@ -89,6 +90,10 @@
|
|||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
|
||||
|
||||
"setCoverDialogTitle": "대표 이미지 변경",
|
||||
"setCoverDialogLatest": "최근 항목",
|
||||
"setCoverDialogCustom": "직접 설정",
|
||||
|
||||
"newAlbumDialogTitle": "새 앨범 만들기",
|
||||
"newAlbumDialogNameLabel": "앨범 이름",
|
||||
"newAlbumDialogNameLabelAlreadyExistsHelper": "사용 중인 이름입니다",
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/media_store_source.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
|
@ -40,8 +43,6 @@ void main() {
|
|||
runApp(AvesApp());
|
||||
}
|
||||
|
||||
enum AppMode { main, pick, view }
|
||||
|
||||
class AvesApp extends StatefulWidget {
|
||||
@override
|
||||
_AvesAppState createState() => _AvesAppState();
|
||||
|
@ -61,56 +62,12 @@ class _AvesAppState extends State<AvesApp> {
|
|||
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||
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();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initPlatformServices();
|
||||
_appSetup = _setup();
|
||||
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||
|
@ -145,7 +102,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: darkTheme,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
locale: settingsLocale,
|
||||
localizationsDelegates: [
|
||||
|
|
|
@ -14,6 +14,7 @@ enum ChipAction {
|
|||
pin,
|
||||
unpin,
|
||||
rename,
|
||||
setCover,
|
||||
goToAlbumPage,
|
||||
goToCountryPage,
|
||||
goToTagPage,
|
||||
|
@ -38,6 +39,8 @@ extension ExtraChipAction on ChipAction {
|
|||
return context.l10n.chipActionUnpin;
|
||||
case ChipAction.rename:
|
||||
return context.l10n.chipActionRename;
|
||||
case ChipAction.setCover:
|
||||
return context.l10n.chipActionSetCover;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -59,6 +62,8 @@ extension ExtraChipAction on ChipAction {
|
|||
return AIcons.pin;
|
||||
case ChipAction.rename:
|
||||
return AIcons.rename;
|
||||
case ChipAction.setCover:
|
||||
return AIcons.setCover;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -8,17 +8,29 @@ import 'package:google_api_availability/google_api_availability.dart';
|
|||
import 'package:package_info/package_info.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;
|
||||
|
||||
AvesAvailability._private() {
|
||||
LiveAvesAvailability() {
|
||||
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResume() => _isConnected = null;
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
if (_isConnected != null) return SynchronousFuture(_isConnected);
|
||||
final result = await (Connectivity().checkConnectivity());
|
||||
|
@ -34,6 +46,7 @@ class AvesAvailability {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> get hasPlayServices async {
|
||||
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
|
||||
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
|
||||
|
@ -43,8 +56,10 @@ class AvesAvailability {
|
|||
}
|
||||
|
||||
// local geocoding with `geocoder` requires Play Services
|
||||
@override
|
||||
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
||||
|
||||
@override
|
||||
Future<bool> get isNewVersionAvailable async {
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);
|
||||
|
||||
|
|
111
lib/model/covers.dart
Normal file
111
lib/model/covers.dart
Normal 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}';
|
||||
}
|
|
@ -3,15 +3,13 @@ import 'dart:async';
|
|||
import 'package:aves/geo/countries.dart';
|
||||
import 'package:aves/model/availability.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_db.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.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/services.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
|
@ -34,7 +32,7 @@ class AvesEntry {
|
|||
int height;
|
||||
int sourceRotationDegrees;
|
||||
final int sizeBytes;
|
||||
String sourceTitle;
|
||||
String _sourceTitle;
|
||||
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
int _dateModifiedSecs;
|
||||
|
@ -59,13 +57,14 @@ class AvesEntry {
|
|||
@required this.height,
|
||||
this.sourceRotationDegrees,
|
||||
this.sizeBytes,
|
||||
this.sourceTitle,
|
||||
String sourceTitle,
|
||||
int dateModifiedSecs,
|
||||
this.sourceDateTakenMillis,
|
||||
this.durationMillis,
|
||||
}) : assert(width != null),
|
||||
assert(height != null) {
|
||||
this.path = path;
|
||||
this.sourceTitle = sourceTitle;
|
||||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
}
|
||||
|
||||
|
@ -74,14 +73,14 @@ class AvesEntry {
|
|||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
AvesEntry copyWith({
|
||||
@required String uri,
|
||||
@required String path,
|
||||
@required int contentId,
|
||||
@required int dateModifiedSecs,
|
||||
String uri,
|
||||
String path,
|
||||
int contentId,
|
||||
int dateModifiedSecs,
|
||||
}) {
|
||||
final copyContentId = contentId ?? this.contentId;
|
||||
final copied = AvesEntry(
|
||||
uri: uri ?? uri,
|
||||
uri: uri ?? this.uri,
|
||||
path: path ?? this.path,
|
||||
contentId: copyContentId,
|
||||
sourceMimeType: sourceMimeType,
|
||||
|
@ -90,7 +89,7 @@ class AvesEntry {
|
|||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
)
|
||||
|
@ -342,6 +341,13 @@ class AvesEntry {
|
|||
|
||||
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
||||
|
||||
String get sourceTitle => _sourceTitle;
|
||||
|
||||
set sourceTitle(String sourceTitle) {
|
||||
_sourceTitle = sourceTitle;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
int get dateModifiedSecs => _dateModifiedSecs;
|
||||
|
||||
set dateModifiedSecs(int dateModifiedSecs) {
|
||||
|
@ -439,7 +445,7 @@ class AvesEntry {
|
|||
}
|
||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||
} 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'];
|
||||
if (contentId is int) this.contentId = contentId;
|
||||
final sourceTitle = newFields['title'];
|
||||
if (sourceTitle is String) {
|
||||
this.sourceTitle = sourceTitle;
|
||||
_bestTitle = null;
|
||||
}
|
||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||
|
||||
final width = newFields['width'];
|
||||
if (width is int) this.width = width;
|
||||
|
@ -576,18 +579,8 @@ class AvesEntry {
|
|||
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 {
|
||||
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||||
final newFields = await imageFileService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -599,7 +592,7 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
Future<bool> flip() async {
|
||||
final newFields = await ImageFileService.flip(this);
|
||||
final newFields = await imageFileService.flip(this);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -612,7 +605,7 @@ class AvesEntry {
|
|||
|
||||
Future<bool> delete() {
|
||||
Completer completer = Completer<bool>();
|
||||
ImageFileService.delete([this]).listen(
|
||||
imageFileService.delete([this]).listen(
|
||||
(event) => completer.complete(event.success),
|
||||
onError: completer.completeError,
|
||||
onDone: () {
|
||||
|
@ -634,23 +627,23 @@ class AvesEntry {
|
|||
|
||||
// favourites
|
||||
|
||||
void toggleFavourite() {
|
||||
Future<void> toggleFavourite() async {
|
||||
if (isFavourite) {
|
||||
removeFromFavourites();
|
||||
await removeFromFavourites();
|
||||
} else {
|
||||
addToFavourites();
|
||||
await addToFavourites();
|
||||
}
|
||||
}
|
||||
|
||||
void addToFavourites() {
|
||||
Future<void> addToFavourites() async {
|
||||
if (!isFavourite) {
|
||||
favourites.add([this]);
|
||||
await favourites.add([this]);
|
||||
}
|
||||
}
|
||||
|
||||
void removeFromFavourites() {
|
||||
Future<void> removeFromFavourites() async {
|
||||
if (isFavourite) {
|
||||
favourites.remove([this]);
|
||||
await favourites.remove([this]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
96
lib/model/favourites.dart
Normal 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}';
|
||||
}
|
|
@ -204,38 +204,3 @@ class AddressDetails {
|
|||
@override
|
||||
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}';
|
||||
}
|
||||
|
|
|
@ -1,15 +1,85 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.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<String> get path async => join(await getDatabasesPath(), 'metadata.db');
|
||||
|
@ -19,9 +89,9 @@ class MetadataDb {
|
|||
static const metadataTable = 'metadata';
|
||||
static const addressTable = 'address';
|
||||
static const favouriteTable = 'favourites';
|
||||
static const coverTable = 'covers';
|
||||
|
||||
MetadataDb._private();
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
debugPrint('$runtimeType init');
|
||||
_database = openDatabase(
|
||||
|
@ -68,17 +138,23 @@ class MetadataDb {
|
|||
'contentId INTEGER PRIMARY KEY'
|
||||
', path TEXT'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $coverTable('
|
||||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
')');
|
||||
},
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 3,
|
||||
version: 4,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> dbFileSize() async {
|
||||
final file = File((await path));
|
||||
return await file.exists() ? file.length() : 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reset() async {
|
||||
debugPrint('$runtimeType reset');
|
||||
await (await _database).close();
|
||||
|
@ -86,7 +162,8 @@ class MetadataDb {
|
|||
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;
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -100,8 +177,9 @@ class MetadataDb {
|
|||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
||||
if (updateFavourites) {
|
||||
if (!metadataOnly) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
|
@ -110,12 +188,14 @@ class MetadataDb {
|
|||
|
||||
// entries
|
||||
|
||||
@override
|
||||
Future<void> clearEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(entryTable, where: '1');
|
||||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -125,6 +205,7 @@ class MetadataDb {
|
|||
return entries;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||
if (entries == null || entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -135,6 +216,7 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
|
@ -154,12 +236,14 @@ class MetadataDb {
|
|||
|
||||
// date taken
|
||||
|
||||
@override
|
||||
Future<void> clearDates() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(dateTakenTable, where: '1');
|
||||
debugPrint('$runtimeType clearDates deleted $count entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DateMetadata>> loadDates() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -171,12 +255,14 @@ class MetadataDb {
|
|||
|
||||
// catalog metadata
|
||||
|
||||
@override
|
||||
Future<void> clearMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(metadataTable, where: '1');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -186,6 +272,7 @@ class MetadataDb {
|
|||
return metadataEntries;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
|
||||
if (metadataEntries == null || metadataEntries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -200,6 +287,7 @@ class MetadataDb {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
|
@ -227,12 +315,14 @@ class MetadataDb {
|
|||
|
||||
// address
|
||||
|
||||
@override
|
||||
Future<void> clearAddresses() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(addressTable, where: '1');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AddressDetails>> loadAddresses() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
|
@ -242,6 +332,7 @@ class MetadataDb {
|
|||
return addresses;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async {
|
||||
if (addresses == null || addresses.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -252,6 +343,7 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAddressId(int oldId, AddressDetails address) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
|
@ -271,31 +363,31 @@ class MetadataDb {
|
|||
|
||||
// favourites
|
||||
|
||||
@override
|
||||
Future<void> clearFavourites() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(favouriteTable, where: '1');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count entries');
|
||||
}
|
||||
|
||||
Future<List<FavouriteRow>> loadFavourites() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
@override
|
||||
Future<Set<FavouriteRow>> loadFavourites() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(favouriteTable);
|
||||
final favouriteRows = maps.map((map) => FavouriteRow.fromMap(map)).toList();
|
||||
// debugPrint('$runtimeType loadFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries');
|
||||
return favouriteRows;
|
||||
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async {
|
||||
if (favouriteRows == null || favouriteRows.isEmpty) return;
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
final db = await _database;
|
||||
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);
|
||||
// debugPrint('$runtimeType addFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
|
@ -313,9 +405,10 @@ class MetadataDb {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async {
|
||||
if (favouriteRows == null || favouriteRows.isEmpty) return;
|
||||
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId);
|
||||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows == null || rows.isEmpty) return;
|
||||
final ids = rows.where((row) => row != null).map((row) => row.contentId);
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
|
@ -324,4 +417,61 @@ class MetadataDb {
|
|||
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
class MetadataDbUpgrader {
|
||||
static const entryTable = MetadataDb.entryTable;
|
||||
static const metadataTable = MetadataDb.metadataTable;
|
||||
static const entryTable = SqfliteMetadataDb.entryTable;
|
||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
|
@ -17,6 +18,9 @@ class MetadataDbUpgrader {
|
|||
case 2:
|
||||
await _upgradeFrom2(db);
|
||||
break;
|
||||
case 3:
|
||||
await _upgradeFrom3(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
|
@ -97,4 +101,12 @@ class MetadataDbUpgrader {
|
|||
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'
|
||||
')');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/covers.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/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/tag.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/source/album.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
@ -99,10 +100,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
eventBus.fire(EntryAddedEvent(entries));
|
||||
}
|
||||
|
||||
void removeEntries(Set<String> uris) {
|
||||
Future<void> removeEntries(Set<String> uris) async {
|
||||
if (uris.isEmpty) return;
|
||||
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);
|
||||
_invalidate(entries);
|
||||
|
||||
|
@ -121,30 +123,61 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
updateTags();
|
||||
}
|
||||
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
|
||||
final oldContentId = entry.contentId;
|
||||
final newContentId = newFields['contentId'] as int;
|
||||
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||
|
||||
entry.contentId = newContentId;
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs;
|
||||
entry.path = newFields['path'] as String;
|
||||
entry.uri = newFields['uri'] as String;
|
||||
entry.contentId = newContentId;
|
||||
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||
if (newFields.containsKey('path')) entry.path = newFields['path'] as String;
|
||||
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
|
||||
if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String;
|
||||
|
||||
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
|
||||
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
|
||||
|
||||
await metadataDb.updateEntryId(oldContentId, entry);
|
||||
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
|
||||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
if (isFavourite) {
|
||||
await favourites.move(oldContentId, entry);
|
||||
await favourites.moveEntry(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({
|
||||
@required Set<AvesEntry> todoEntries,
|
||||
@required Set<AvesEntry> favouriteEntries,
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
@required Set<MoveOpEvent> movedOps,
|
||||
|
@ -178,10 +211,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
movedEntries.add(entry);
|
||||
// do not rely on current favourite repo state to assess whether the moved entry is a favourite
|
||||
// as source monitoring may already have removed the entry from the favourite repo
|
||||
final isFavourite = favouriteEntries.contains(entry);
|
||||
await _moveEntry(entry, newFields, isFavourite);
|
||||
await _moveEntry(entry, newFields);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -232,6 +262,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
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) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
if (visible) {
|
||||
|
|
|
@ -5,9 +5,9 @@ import 'package:aves/model/availability.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.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/enums.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
|
@ -27,7 +25,8 @@ class MediaStoreSource extends CollectionSource {
|
|||
stateNotifier.value = SourceState.loading;
|
||||
await metadataDb.init();
|
||||
await favourites.init();
|
||||
final currentTimeZone = await TimeService.getDefaultTimeZone();
|
||||
await covers.init();
|
||||
final currentTimeZone = await timeService.getDefaultTimeZone();
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
|
@ -51,7 +50,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||
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));
|
||||
|
||||
// show known entries
|
||||
|
@ -61,11 +60,11 @@ class MediaStoreSource extends CollectionSource {
|
|||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||
|
||||
// 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`
|
||||
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) {
|
||||
// make obsolete by resetting its modified date
|
||||
knownDateById[contentId] = 0;
|
||||
|
@ -82,7 +81,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
pendingNewEntries.clear();
|
||||
}
|
||||
|
||||
MediaStoreService.getEntries(knownDateById).listen(
|
||||
mediaStoreService.getEntries(knownDateById).listen(
|
||||
(entry) {
|
||||
pendingNewEntries.add(entry);
|
||||
if (pendingNewEntries.length >= refreshCount) {
|
||||
|
@ -115,6 +114,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
}
|
||||
|
||||
void _reportCollectionDimensions() {
|
||||
if (!settings.isCrashlyticsEnabled) return;
|
||||
final analytics = FirebaseAnalytics();
|
||||
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
|
||||
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
|
||||
|
@ -142,9 +142,9 @@ class MediaStoreSource extends CollectionSource {
|
|||
}).where((kv) => kv != null));
|
||||
|
||||
// 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();
|
||||
removeEntries(obsoleteUris);
|
||||
await removeEntries(obsoleteUris);
|
||||
obsoleteContentIds.forEach(uriByContentId.remove);
|
||||
|
||||
// fetch new entries
|
||||
|
@ -154,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
for (final kv in uriByContentId.entries) {
|
||||
final contentId = kv.key;
|
||||
final uri = kv.value;
|
||||
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
||||
final sourceEntry = await imageFileService.getEntry(uri, null);
|
||||
if (sourceEntry != null) {
|
||||
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||
// compare paths because some apps move files without updating their `last modified date`
|
||||
|
@ -189,7 +189,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
@override
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
metadataDb.removeIds(contentIds, updateFavourites: false);
|
||||
metadataDb.removeIds(contentIds, metadataOnly: true);
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/tag.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/enums.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:aves/model/entry.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/services.dart';
|
||||
|
||||
|
@ -30,7 +30,7 @@ class AppShortcutService {
|
|||
Uint8List iconBytes;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await ImageFileService.getThumbnail(
|
||||
iconBytes = await imageFileService.getThumbnail(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
pageId: entry.pageId,
|
||||
|
|
|
@ -11,7 +11,82 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter/widgets.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 final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
|
||||
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 {
|
||||
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -44,7 +120,8 @@ class ImageFileService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List> getSvg(
|
||||
@override
|
||||
Future<Uint8List> getSvg(
|
||||
String uri,
|
||||
String mimeType, {
|
||||
int expectedContentLength,
|
||||
|
@ -59,7 +136,8 @@ class ImageFileService {
|
|||
onBytesReceived: onBytesReceived,
|
||||
);
|
||||
|
||||
static Future<Uint8List> getImage(
|
||||
@override
|
||||
Future<Uint8List> getImage(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int rotationDegrees,
|
||||
|
@ -106,8 +184,8 @@ class ImageFileService {
|
|||
return Future.sync(() => null);
|
||||
}
|
||||
|
||||
// `rect`: region to decode, with coordinates in reference to `imageSize`
|
||||
static Future<Uint8List> getRegion(
|
||||
@override
|
||||
Future<Uint8List> getRegion(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int rotationDegrees,
|
||||
|
@ -145,7 +223,8 @@ class ImageFileService {
|
|||
);
|
||||
}
|
||||
|
||||
static Future<Uint8List> getThumbnail({
|
||||
@override
|
||||
Future<Uint8List> getThumbnail({
|
||||
@required String uri,
|
||||
@required String mimeType,
|
||||
@required int rotationDegrees,
|
||||
|
@ -184,7 +263,8 @@ class ImageFileService {
|
|||
);
|
||||
}
|
||||
|
||||
static Future<void> clearSizedThumbnailDiskCache() async {
|
||||
@override
|
||||
Future<void> clearSizedThumbnailDiskCache() async {
|
||||
try {
|
||||
return platform.invokeMethod('clearSizedThumbnailDiskCache');
|
||||
} 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 {
|
||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'delete',
|
||||
|
@ -210,7 +294,8 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Stream<MoveOpEvent> move(
|
||||
@override
|
||||
Stream<MoveOpEvent> move(
|
||||
Iterable<AvesEntry> entries, {
|
||||
@required bool copy,
|
||||
@required String destinationAlbum,
|
||||
|
@ -228,7 +313,8 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Stream<ExportOpEvent> export(
|
||||
@override
|
||||
Stream<ExportOpEvent> export(
|
||||
Iterable<AvesEntry> entries, {
|
||||
String mimeType = MimeTypes.jpeg,
|
||||
@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 {
|
||||
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||
|
@ -260,7 +347,8 @@ class ImageFileService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||
@override
|
||||
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
|
@ -274,7 +362,8 @@ class ImageFileService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Map> flip(AvesEntry entry) async {
|
||||
@override
|
||||
Future<Map> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
|
|
|
@ -5,11 +5,21 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter/widgets.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 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 {
|
||||
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
|
||||
'knownContentIds': knownContentIds,
|
||||
|
@ -21,7 +31,8 @@ class MediaStoreService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
|
||||
@override
|
||||
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
|
||||
'knownPathById': knownPathById,
|
||||
|
@ -33,8 +44,8 @@ class MediaStoreService {
|
|||
return [];
|
||||
}
|
||||
|
||||
// knownEntries: map of contentId -> dateModifiedSecs
|
||||
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
|
||||
@override
|
||||
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
|
||||
try {
|
||||
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'knownEntries': knownEntries,
|
||||
|
|
|
@ -8,11 +8,32 @@ import 'package:aves/services/service_policy.dart';
|
|||
import 'package:flutter/foundation.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');
|
||||
|
||||
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
static Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||
@override
|
||||
Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
|
@ -28,7 +49,8 @@ class MetadataService {
|
|||
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;
|
||||
|
||||
Future<CatalogMetadata> call() async {
|
||||
|
@ -65,7 +87,8 @@ class MetadataService {
|
|||
: call();
|
||||
}
|
||||
|
||||
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
|
||||
@override
|
||||
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
|
@ -82,7 +105,8 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
|
||||
@override
|
||||
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -96,7 +120,8 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||
@override
|
||||
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with values for:
|
||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||
|
@ -113,7 +138,8 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
@override
|
||||
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
try {
|
||||
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -126,7 +152,8 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||
@override
|
||||
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -138,7 +165,8 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||
@override
|
||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
@ -152,7 +180,8 @@ class MetadataService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||
@override
|
||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
|
|
27
lib/services/services.dart
Normal file
27
lib/services/services.dart
Normal 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());
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -17,7 +17,7 @@ class SvgMetadataService {
|
|||
|
||||
static Future<Size> getSize(AvesEntry entry) async {
|
||||
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 root = document.rootElement;
|
||||
|
@ -59,7 +59,7 @@ class SvgMetadataService {
|
|||
}
|
||||
|
||||
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 root = document.rootElement;
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import 'package:flutter/foundation.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 Future<String> getDefaultTimeZone() async {
|
||||
@override
|
||||
Future<String> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e) {
|
||||
|
|
|
@ -48,6 +48,7 @@ class AIcons {
|
|||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||
static const IconData search = Icons.search_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 sort = Icons.sort_outlined;
|
||||
static const IconData stats = Icons.pie_chart_outlined;
|
||||
|
|
51
lib/theme/themes.dart
Normal file
51
lib/theme/themes.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -159,6 +159,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
|
||||
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(
|
||||
name: 'Github',
|
||||
license: 'MIT',
|
||||
|
|
|
@ -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/widgets/about/news_badge.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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/entry_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -96,24 +96,29 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) => SliverAppBar(
|
||||
leading: _buildAppBarLeading(),
|
||||
title: _buildAppBarTitle(),
|
||||
actions: _buildActions(),
|
||||
bottom: hasFilters
|
||||
? FilterBar(
|
||||
filters: collection.filters,
|
||||
onPressed: collection.removeFilter,
|
||||
)
|
||||
: null,
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading() : null,
|
||||
title: _buildAppBarTitle(),
|
||||
actions: _buildActions(),
|
||||
bottom: hasFilters
|
||||
? FilterBar(
|
||||
filters: collection.filters,
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
)
|
||||
: null,
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -143,7 +148,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
Widget _buildAppBarTitle() {
|
||||
if (collection.isBrowsing) {
|
||||
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) {
|
||||
title = SourceStateAwareAppBarTitle(
|
||||
title: title,
|
||||
|
@ -151,7 +156,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
return InteractiveAppBarTitle(
|
||||
onTap: _goToSearch,
|
||||
onTap: appMode.canSearch ? _goToSearch : null,
|
||||
child: title,
|
||||
);
|
||||
} else if (collection.isSelecting) {
|
||||
|
@ -167,8 +172,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return [
|
||||
if (collection.isBrowsing)
|
||||
if (collection.isBrowsing && appMode.canSearch)
|
||||
CollectionSearchButton(
|
||||
source,
|
||||
parentCollection: collection,
|
||||
|
@ -193,7 +199,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
itemBuilder: (context) {
|
||||
final isNotEmpty = !collection.isEmpty;
|
||||
final hasSelection = collection.selection.isNotEmpty;
|
||||
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: Key('menu-sort'),
|
||||
|
@ -206,19 +211,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
value: CollectionAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
),
|
||||
if (collection.isBrowsing) ...[
|
||||
if (isMainMode)
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.select,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
|
||||
),
|
||||
if (collection.isBrowsing && appMode == AppMode.main) ...[
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.select,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.stats,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
|
||||
),
|
||||
if (isMainMode && canAddShortcuts)
|
||||
if (canAddShortcuts)
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.addShortcut,
|
||||
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/main.dart';
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
|
@ -34,6 +34,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
class CollectionGrid extends StatefulWidget {
|
||||
final String settingsRouteKey;
|
||||
|
||||
const CollectionGrid({
|
||||
this.settingsRouteKey,
|
||||
});
|
||||
|
||||
@override
|
||||
_CollectionGridState createState() => _CollectionGridState();
|
||||
}
|
||||
|
@ -44,7 +50,7 @@ class _CollectionGridState extends State<CollectionGrid> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_tileExtentController ??= TileExtentController(
|
||||
settingsRouteKey: context.currentRouteName,
|
||||
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName,
|
||||
columnCountDefault: 4,
|
||||
extentMin: 46,
|
||||
spacing: 0,
|
||||
|
|
|
@ -8,8 +8,8 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/image_file_service.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/widgets/common/action_mixins/feedback.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 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();
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
|
||||
opStream: imageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final movedOps = processed.where((e) => e.success).toSet();
|
||||
await source.updateAfterMove(
|
||||
todoEntries: todoEntries,
|
||||
favouriteEntries: favouriteEntries,
|
||||
copy: copy,
|
||||
destinationAlbum: destinationAlbum,
|
||||
movedOps: movedOps,
|
||||
|
@ -119,13 +115,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
collection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
final l10n = context.l10n;
|
||||
final movedCount = movedOps.length;
|
||||
if (movedCount < todoCount) {
|
||||
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 {
|
||||
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();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: ImageFileService.delete(selection),
|
||||
opStream: imageFileService.delete(selection),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) {
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
source.removeEntries(deletedUris);
|
||||
await source.removeEntries(deletedUris);
|
||||
collection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
|
|
|
@ -8,12 +8,14 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
|
||||
|
||||
final List<CollectionFilter> filters;
|
||||
final FilterCallback onPressed;
|
||||
final bool removable;
|
||||
final FilterCallback onTap;
|
||||
|
||||
FilterBar({
|
||||
Key key,
|
||||
@required Set<CollectionFilter> filters,
|
||||
@required this.onPressed,
|
||||
@required this.removable,
|
||||
this.onTap,
|
||||
}) : filters = List<CollectionFilter>.from(filters)..sort(),
|
||||
super(key: key);
|
||||
|
||||
|
@ -26,7 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
|
||||
class _FilterBarState extends State<FilterBar> {
|
||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
|
||||
CollectionFilter _userRemovedFilter;
|
||||
CollectionFilter _userTappedFilter;
|
||||
|
||||
FilterCallback get onTap => widget.onTap;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilterBar oldWidget) {
|
||||
|
@ -41,7 +45,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
existing.removeAt(index);
|
||||
// only animate item removal when triggered by a user interaction with the chip,
|
||||
// not from automatic chip replacement following chip selection
|
||||
final animate = _userRemovedFilter == filter;
|
||||
final animate = _userTappedFilter == filter;
|
||||
listState.removeItem(
|
||||
index,
|
||||
animate
|
||||
|
@ -70,7 +74,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
duration: Duration.zero,
|
||||
);
|
||||
});
|
||||
_userRemovedFilter = null;
|
||||
_userTappedFilter = null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -106,12 +110,14 @@ class _FilterBarState extends State<FilterBar> {
|
|||
child: AvesFilterChip(
|
||||
key: ValueKey(filter),
|
||||
filter: filter,
|
||||
removable: true,
|
||||
removable: widget.removable,
|
||||
heroType: HeroType.always,
|
||||
onTap: (filter) {
|
||||
_userRemovedFilter = filter;
|
||||
widget.onPressed(filter);
|
||||
},
|
||||
onTap: onTap != null
|
||||
? (filter) {
|
||||
_userTappedFilter = filter;
|
||||
onTap(filter);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
|
@ -29,14 +29,22 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
key: ValueKey(entry.uri),
|
||||
onTap: () {
|
||||
final appMode = context.read<ValueNotifier<AppMode>>().value;
|
||||
if (appMode == AppMode.main) {
|
||||
if (collection.isBrowsing) {
|
||||
_goToViewer(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
} else if (appMode == AppMode.pick) {
|
||||
ViewerService.pick(entry.uri);
|
||||
switch (appMode) {
|
||||
case AppMode.main:
|
||||
if (collection.isBrowsing) {
|
||||
_goToViewer(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
break;
|
||||
case AppMode.pickExternal:
|
||||
ViewerService.pick(entry.uri);
|
||||
break;
|
||||
case AppMode.pickInternal:
|
||||
Navigator.pop(context, entry);
|
||||
break;
|
||||
case AppMode.view:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: MetaData(
|
||||
|
|
|
@ -7,7 +7,12 @@ mixin FeedbackMixin {
|
|||
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
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),
|
||||
duration: Durations.opToastDisplay,
|
||||
));
|
||||
|
|
|
@ -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/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -24,11 +24,11 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final bool showGenericIcon;
|
||||
final Widget background;
|
||||
final Widget details;
|
||||
final BorderRadius borderRadius;
|
||||
final double padding;
|
||||
final HeroType heroType;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
static const Color defaultOutlineColor = Colors.white;
|
||||
static const double defaultRadius = 32;
|
||||
|
@ -100,6 +100,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
|
||||
double get padding => widget.padding;
|
||||
|
||||
FilterCallback get onTap => widget.onTap;
|
||||
|
||||
OffsetFilterCallback get onLongPress => widget.onLongPress;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -218,14 +222,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
child: InkWell(
|
||||
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
|
||||
// so we get the long press details from the tap instead
|
||||
onTapDown: (details) => _tapPosition = details.globalPosition,
|
||||
onTap: widget.onTap != null
|
||||
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
|
||||
onTap: onTap != null
|
||||
? () {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onTap(filter));
|
||||
setState(() => _tapped = true);
|
||||
}
|
||||
: null,
|
||||
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
|
||||
onLongPress: onLongPress != null ? () => onLongPress(context, filter, _tapPosition) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
|
|
|
@ -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/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -60,7 +60,7 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
|
|||
),
|
||||
SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
|
||||
onPressed: imageFileService.clearSizedThumbnailDiskCache,
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:aves/model/covers.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_db.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -17,7 +18,8 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
Future<List<DateMetadata>> _dbDateLoader;
|
||||
Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||
Future<List<AddressDetails>> _dbAddressLoader;
|
||||
Future<List<FavouriteRow>> _dbFavouritesLoader;
|
||||
Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||
Future<Set<CoverRow>> _dbCoversLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -141,7 +143,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<List>(
|
||||
FutureBuilder<Set>(
|
||||
future: _dbFavouritesLoader,
|
||||
builder: (context, snapshot) {
|
||||
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();
|
||||
_dbAddressLoader = metadataDb.loadAddresses();
|
||||
_dbFavouritesLoader = metadataDb.loadFavourites();
|
||||
_dbCoversLoader = metadataDb.loadCovers();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
|
133
lib/widgets/dialogs/cover_selection_dialog.dart
Normal file
133
lib/widgets/dialogs/cover_selection_dialog.dart
Normal 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(() {});
|
||||
}
|
||||
}
|
||||
}
|
51
lib/widgets/dialogs/item_pick_dialog.dart
Normal file
51
lib/widgets/dialogs/item_pick_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.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/tag.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/about/about_page.dart';
|
||||
|
|
|
@ -40,6 +40,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
chipActionDelegate: AlbumChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.rename,
|
||||
ChipAction.delete,
|
||||
ChipAction.hide,
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
import 'package:aves/model/actions/chip_actions.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/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.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/services.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/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/cover_selection_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/countries_page.dart';
|
||||
|
@ -21,6 +24,7 @@ import 'package:aves/widgets/filter_grids/tags_page.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ChipActionDelegate {
|
||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
||||
|
@ -34,6 +38,9 @@ class ChipActionDelegate {
|
|||
case ChipAction.hide:
|
||||
_hide(context, filter);
|
||||
break;
|
||||
case ChipAction.setCover:
|
||||
_showCoverSelectionDialog(context, filter);
|
||||
break;
|
||||
case ChipAction.goToAlbumPage:
|
||||
_goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage());
|
||||
break;
|
||||
|
@ -74,6 +81,22 @@ class ChipActionDelegate {
|
|||
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(
|
||||
BuildContext context,
|
||||
CollectionFilter filter,
|
||||
|
@ -140,11 +163,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: ImageFileService.delete(selection),
|
||||
opStream: imageFileService.delete(selection),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) {
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
source.removeEntries(deletedUris);
|
||||
await source.removeEntries(deletedUris);
|
||||
source.resumeMonitoring();
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
|
@ -182,38 +205,26 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
|
||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
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();
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
|
||||
opStream: imageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final movedOps = processed.where((e) => e.success).toSet();
|
||||
final pinned = settings.pinnedFilters.contains(filter);
|
||||
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);
|
||||
}
|
||||
await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps);
|
||||
source.resumeMonitoring();
|
||||
|
||||
final movedCount = movedOps.length;
|
||||
if (movedCount < todoCount) {
|
||||
final count = todoCount - movedCount;
|
||||
showFeedback(context, context.l10n.collectionMoveFailureFeedback(count));
|
||||
showFeedbackWithMessenger(messenger, l10n.collectionMoveFailureFeedback(count));
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
|
@ -25,6 +26,7 @@ import 'package:provider/provider.dart';
|
|||
class DecoratedFilterChip extends StatelessWidget {
|
||||
final CollectionFilter filter;
|
||||
final double extent;
|
||||
final AvesEntry coverEntry;
|
||||
final bool pinned, highlightable;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
@ -33,6 +35,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
Key key,
|
||||
@required this.filter,
|
||||
@required this.extent,
|
||||
this.coverEntry,
|
||||
this.pinned = false,
|
||||
this.highlightable = true,
|
||||
this.onTap,
|
||||
|
@ -76,7 +79,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildChip(CollectionSource source) {
|
||||
final entry = source.recentEntry(filter);
|
||||
final entry = coverEntry ?? source.coverEntry(filter);
|
||||
final backgroundImage = entry == null
|
||||
? Container(color: Colors.white)
|
||||
: entry.isSvg
|
||||
|
@ -89,7 +92,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
extent: extent,
|
||||
);
|
||||
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));
|
||||
Widget child = AvesFilterChip(
|
||||
filter: filter,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -64,17 +65,20 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: FilterGrid<T>(
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: appBarHeight,
|
||||
filterSections: filterSections,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: AnimatedBuilder(
|
||||
animation: covers,
|
||||
builder: (context, child) => FilterGrid<T>(
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: appBarHeight,
|
||||
filterSections: filterSections,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
|
|
@ -35,6 +35,7 @@ class CountryListPage extends StatelessWidget {
|
|||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.hide,
|
||||
],
|
||||
filterSections: _getCountryEntries(source),
|
||||
|
|
|
@ -35,6 +35,7 @@ class TagListPage extends StatelessWidget {
|
|||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.hide,
|
||||
],
|
||||
filterSections: _getTagEntries(source),
|
||||
|
|
|
@ -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/filters/filters.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/source/collection_lens.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/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
|
@ -81,7 +81,7 @@ class _HomePageState extends State<HomePage> {
|
|||
}
|
||||
break;
|
||||
case 'pick':
|
||||
appMode = AppMode.pick;
|
||||
appMode = AppMode.pickExternal;
|
||||
// TODO TLAD apply pick mimetype(s)
|
||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
||||
String pickMimeTypes = intentData['mimeType'];
|
||||
|
@ -110,7 +110,7 @@ class _HomePageState extends State<HomePage> {
|
|||
}
|
||||
|
||||
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) {
|
||||
// cataloguing is essential for coordinates and video rotation
|
||||
await entry.catalog();
|
||||
|
@ -130,7 +130,7 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
String routeName;
|
||||
Iterable<CollectionFilter> filters;
|
||||
if (appMode == AppMode.pick) {
|
||||
if (appMode == AppMode.pickExternal) {
|
||||
routeName = CollectionPage.routeName;
|
||||
} else {
|
||||
routeName = _shortcutRouteName ?? settings.homePage.routeName;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/model/entry.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:flutter/material.dart';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/app_mode.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_images.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
|
|
@ -6,9 +6,8 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_file_service.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/permission_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);
|
||||
} else {
|
||||
if (hasCollection) {
|
||||
collection.source.removeEntries({entry.uri});
|
||||
await collection.source.removeEntries({entry.uri});
|
||||
}
|
||||
EntryDeletedNotification(entry).dispatch(context);
|
||||
}
|
||||
|
@ -170,7 +169,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
|
||||
final selection = <AvesEntry>{};
|
||||
if (entry.isMultipage) {
|
||||
final multiPageInfo = await MetadataService.getMultiPageInfo(entry);
|
||||
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
|
||||
if (multiPageInfo.pageCount > 1) {
|
||||
for (final page in multiPageInfo.pages) {
|
||||
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
|
||||
|
@ -184,7 +183,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
final selectionCount = selection.length;
|
||||
showOpReport<ExportOpEvent>(
|
||||
context: context,
|
||||
opStream: ImageFileService.export(selection, destinationAlbum: destinationAlbum),
|
||||
opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) {
|
||||
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 entry.rename(newName)) {
|
||||
final success = await context.read<CollectionSource>().renameEntry(entry, newName);
|
||||
|
||||
if (success) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
|
@ -221,7 +222,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: SourceViewerPage.routeName),
|
||||
builder: (context) => SourceViewerPage(
|
||||
loader: () => ImageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
|
||||
loader: () => imageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.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/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.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/favourite.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/source/collection_lens.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/file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -87,7 +87,7 @@ class BasicSection extends StatelessWidget {
|
|||
...tags.map((tag) => TagFilter(tag)),
|
||||
};
|
||||
return AnimatedBuilder(
|
||||
animation: favourites.changeNotifier,
|
||||
animation: favourites,
|
||||
builder: (context, child) {
|
||||
final effectiveFilters = [
|
||||
...filters,
|
||||
|
@ -188,20 +188,21 @@ class _OwnerPropState extends State<OwnerProp> {
|
|||
),
|
||||
// `com.android.shell` is the package reported
|
||||
// for images copied to the device by ADB for Test Driver
|
||||
if (_ownerPackage != 'com.android.shell') WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Image(
|
||||
image: AppIconImage(
|
||||
packageName: _ownerPackage,
|
||||
size: iconSize,
|
||||
if (_ownerPackage != 'com.android.shell')
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Image(
|
||||
image: AppIconImage(
|
||||
packageName: _ownerPackage,
|
||||
size: iconSize,
|
||||
),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: appName,
|
||||
style: InfoRowGroup.baseStyle,
|
||||
|
@ -217,7 +218,7 @@ class _OwnerPropState extends State<OwnerProp> {
|
|||
if (entry == null) return;
|
||||
if (_loadedUri.value == entry.uri) return;
|
||||
if (isVisible) {
|
||||
_ownerPackage = await MetadataService.getContentResolverProp(widget.entry, 'owner_package_name');
|
||||
_ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
|
||||
_loadedUri.value = entry.uri;
|
||||
} else {
|
||||
_ownerPackage = null;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/settings.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/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/settings.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/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:collection';
|
||||
|
||||
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/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -138,7 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
if (entry == null) return;
|
||||
if (_loadedMetadataUri.value == entry.uri) return;
|
||||
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) {
|
||||
var directoryName = dirKV.key as String ?? '';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
import 'dart:typed_data';
|
||||
|
||||
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:provider/provider.dart';
|
||||
|
||||
|
@ -34,10 +34,10 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
|||
super.initState();
|
||||
switch (widget.source) {
|
||||
case MetadataThumbnailSource.embedded:
|
||||
_loader = MetadataService.getEmbeddedPictures(uri);
|
||||
_loader = metadataService.getEmbeddedPictures(uri);
|
||||
break;
|
||||
case MetadataThumbnailSource.exif:
|
||||
_loader = MetadataService.getExifThumbnails(entry);
|
||||
_loader = metadataService.getExifThumbnails(entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/ref/xmp.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/extensions/build_context.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 {
|
||||
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')) {
|
||||
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
|
||||
return;
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/model/entry.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/material.dart';
|
||||
|
||||
|
@ -11,7 +11,7 @@ class MultiPageController extends ChangeNotifier {
|
|||
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
|
||||
|
||||
MultiPageController(AvesEntry entry) {
|
||||
info = MetadataService.getMultiPageInfo(entry).then((value) {
|
||||
info = metadataService.getMultiPageInfo(entry).then((value) {
|
||||
pageNotifier.value = value.defaultPage.index;
|
||||
return value;
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/model/metadata.dart';
|
|||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.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/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
|
@ -69,7 +69,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
}
|
||||
|
||||
void _initDetailLoader() {
|
||||
_detailLoader = MetadataService.getOverlayMetadata(entry);
|
||||
_detailLoader = metadataService.getOverlayMetadata(entry);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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/viewer/overlay/common.dart';
|
||||
import 'package:aves/widgets/viewer/panorama_page.dart';
|
||||
|
@ -25,7 +25,7 @@ class PanoramaOverlay extends StatelessWidget {
|
|||
scale: scale,
|
||||
buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel,
|
||||
onPressed: () async {
|
||||
final info = await MetadataService.getPanoramaInfo(entry);
|
||||
final info = await metadataService.getPanoramaInfo(entry);
|
||||
if (info != null) {
|
||||
unawaited(Navigator.push(
|
||||
context,
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/actions/entry_actions.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/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -323,7 +323,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
favourites.changeNotifier.addListener(_onChanged);
|
||||
favourites.addListener(_onChanged);
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
|
@ -335,7 +335,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
favourites.changeNotifier.removeListener(_onChanged);
|
||||
favourites.removeListener(_onChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
@ -3,8 +3,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/services/image_file_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/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -49,7 +48,7 @@ class EntryPrinter with FeedbackMixin {
|
|||
}
|
||||
|
||||
if (entry.isMultipage) {
|
||||
final multiPageInfo = await MetadataService.getMultiPageInfo(entry);
|
||||
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
|
||||
if (multiPageInfo.pageCount > 1) {
|
||||
final streamController = StreamController<AvesEntry>.broadcast();
|
||||
showOpReport<AvesEntry>(
|
||||
|
@ -73,7 +72,7 @@ class EntryPrinter with FeedbackMixin {
|
|||
|
||||
Future<pdf.Widget> _buildPageImage(AvesEntry entry) async {
|
||||
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) {
|
||||
return pdf.SvgImage(svg: utf8.decode(bytes));
|
||||
}
|
||||
|
|
|
@ -371,6 +371,13 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -49,6 +49,7 @@ dependencies:
|
|||
flutter_markdown:
|
||||
flutter_staggered_animations:
|
||||
flutter_svg:
|
||||
get_it:
|
||||
github:
|
||||
google_api_availability:
|
||||
google_maps_flutter:
|
||||
|
|
8
test/fake/availability.dart
Normal file
8
test/fake/availability.dart
Normal 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);
|
||||
}
|
21
test/fake/image_file_service.dart
Normal file
21
test/fake/image_file_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
62
test/fake/media_store_service.dart
Normal file
62
test/fake/media_store_service.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
66
test/fake/metadata_db.dart
Normal file
66
test/fake/metadata_db.dart
Normal 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;
|
||||
}
|
9
test/fake/metadata_service.dart
Normal file
9
test/fake/metadata_service.dart
Normal 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;
|
||||
}
|
8
test/fake/time_service.dart
Normal file
8
test/fake/time_service.dart
Normal 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('');
|
||||
}
|
239
test/model/collection_source_test.dart
Normal file
239
test/model/collection_source_test.dart
Normal 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);
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue