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