diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6ee2c7e33..727801e59 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -354,6 +354,10 @@ "settingsActionExport": "Exportieren", "settingsActionImport": "Importieren", + "appExportCovers": "Titelbilder", + "appExportFavourites": "Favoriten", + "appExportSettings": "Einstellungen", + "settingsSectionNavigation": "Navigation", "settingsHome": "Startseite", "settingsKeepScreenOnTile": "Bildschirm eingeschaltet lassen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c8cc1ca4b..11f2dbfa7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -523,6 +523,10 @@ "settingsActionExport": "Export", "settingsActionImport": "Import", + "appExportCovers": "Covers", + "appExportFavourites": "Favourites", + "appExportSettings": "Settings", + "settingsSectionNavigation": "Navigation", "settingsHome": "Home", "settingsKeepScreenOnTile": "Keep screen on", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 210df2ddc..7e1995151 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -40,7 +40,7 @@ "chipActionPin": "Fijar", "chipActionUnpin": "Dejar de fijar", "chipActionRename": "Renombrar", - "chipActionSetCover": "Elegir portada", + "chipActionSetCover": "Elegir carátula", "chipActionCreateAlbum": "Crear álbum", "entryActionCopyToClipboard": "Copiar al portapapeles", @@ -355,6 +355,10 @@ "settingsActionExport": "Exportar", "settingsActionImport": "Importar", + "appExportCovers": "Carátulas", + "appExportFavourites": "Favoritos", + "appExportSettings": "Ajustes", + "settingsSectionNavigation": "Navegación", "settingsHome": "Inicio", "settingsKeepScreenOnTile": "Mantener pantalla encendida", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 06182af36..3fbdbfee0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -354,6 +354,10 @@ "settingsActionExport": "Exporter", "settingsActionImport": "Importer", + "appExportCovers": "Couvertures", + "appExportFavourites": "Favoris", + "appExportSettings": "Réglages", + "settingsSectionNavigation": "Navigation", "settingsHome": "Page d’accueil", "settingsKeepScreenOnTile": "Maintenir l’écran allumé", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0496814fb..557e7c2d8 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -354,6 +354,10 @@ "settingsActionExport": "내보내기", "settingsActionImport": "가져오기", + "appExportCovers": "대표 이미지", + "appExportFavourites": "즐겨찾기", + "appExportSettings": "설정", + "settingsSectionNavigation": "탐색", "settingsHome": "홈", "settingsKeepScreenOnTile": "화면 자동 꺼짐 방지", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index bbe8c56bb..b530d69d9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -354,6 +354,10 @@ "settingsActionExport": "Exportar", "settingsActionImport": "Importar", + "appExportCovers": "Capas", + "appExportFavourites": "Favoritos", + "appExportSettings": "Configurações", + "settingsSectionNavigation": "Navegação", "settingsHome": "Início", "settingsKeepScreenOnTile": "Manter a tela ligada", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e2e5d26eb..508fe8ade 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -354,6 +354,9 @@ "settingsActionExport": "Экспорт", "settingsActionImport": "Импорт", + "appExportFavourites": "Избранное", + "appExportSettings": "Настройки", + "settingsSectionNavigation": "Навигация", "settingsHome": "Домашний каталог", "settingsKeepScreenOnTile": "Держать экран включенным", diff --git a/lib/model/covers.dart b/lib/model/covers.dart index d10e67ca7..2b339889c 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -1,11 +1,12 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; final Covers covers = Covers._private(); @@ -20,6 +21,8 @@ class Covers with ChangeNotifier { int get count => _rows.length; + Set get all => Set.unmodifiable(_rows); + int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId; Future set(CollectionFilter filter, int? contentId) async { @@ -75,6 +78,61 @@ class Covers with ChangeNotifier { notifyListeners(); } + + // import/export + + List>? export(CollectionSource source) { + final visibleEntries = source.visibleEntries; + final jsonList = covers.all + .map((row) { + final id = row.contentId; + final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path; + if (path == null) return null; + + final volume = androidFileUtils.getStorageVolume(path)?.path; + if (volume == null) return null; + + final relativePath = path.substring(volume.length); + return { + 'filter': row.filter.toJson(), + 'volume': volume, + 'relativePath': relativePath, + }; + }) + .whereNotNull() + .toList(); + return jsonList.isNotEmpty ? jsonList : null; + } + + void import(dynamic jsonList, CollectionSource source) { + if (jsonList is! List) { + debugPrint('failed to import covers for jsonMap=$jsonList'); + return; + } + + final visibleEntries = source.visibleEntries; + jsonList.forEach((row) { + final filter = CollectionFilter.fromJson(row['filter']); + if (filter == null) { + debugPrint('failed to import cover for row=$row'); + return; + } + + final volume = row['volume']; + final relativePath = row['relativePath']; + if (volume is String && relativePath is String) { + final path = pContext.join(volume, relativePath); + final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry)); + if (entry != null) { + covers.set(filter, entry.contentId); + } else { + debugPrint('failed to import cover for path=$path, filter=$filter'); + } + } else { + debugPrint('failed to import cover for volume=$volume, relativePath=$relativePath, filter=$filter'); + } + }); + } } @immutable diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 57b4684c6..c10467f77 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -57,7 +57,7 @@ extension ExtraAvesEntryImages on AvesEntry { bool _isReady(Object providerKey) => imageCache!.statusForKey(providerKey).keepAlive; - List get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map((key) => ThumbnailProvider(key)).toList(); + List get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map(ThumbnailProvider.new).toList(); ThumbnailProvider get bestCachedThumbnail { final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady); diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index aef618695..cc8768f1e 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -1,5 +1,7 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; @@ -17,6 +19,8 @@ class Favourites with ChangeNotifier { int get count => _rows.length; + Set get all => Set.unmodifiable(_rows.map((v) => v.contentId)); + bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId); FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!); @@ -59,6 +63,56 @@ class Favourites with ChangeNotifier { notifyListeners(); } + + // import/export + + Map>? export(CollectionSource source) { + final visibleEntries = source.visibleEntries; + final ids = favourites.all; + final paths = visibleEntries.where((entry) => ids.contains(entry.contentId)).map((entry) => entry.path).whereNotNull().toSet(); + final byVolume = groupBy(paths, androidFileUtils.getStorageVolume); + final jsonMap = Map.fromEntries(byVolume.entries.map((kv) { + final volume = kv.key?.path; + if (volume == null) return null; + final rootLength = volume.length; + final relativePaths = kv.value.map((v) => v.substring(rootLength)).toList(); + return MapEntry(volume, relativePaths); + }).whereNotNull()); + return jsonMap.isNotEmpty ? jsonMap : null; + } + + void import(dynamic jsonMap, CollectionSource source) { + if (jsonMap is! Map) { + debugPrint('failed to import favourites for jsonMap=$jsonMap'); + return; + } + + final visibleEntries = source.visibleEntries; + final foundEntries = {}; + final missedPaths = {}; + jsonMap.forEach((volume, relativePaths) { + if (volume is String && relativePaths is List) { + relativePaths.forEach((relativePath) { + final path = pContext.join(volume, relativePath); + final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path); + if (entry != null) { + foundEntries.add(entry); + } else { + missedPaths.add(path); + } + }); + } else { + debugPrint('failed to import favourites for volume=$volume, relativePaths=${relativePaths.runtimeType}'); + } + + if (foundEntries.isNotEmpty) { + favourites.add(foundEntries); + } + if (missedPaths.isNotEmpty) { + debugPrint('failed to import favourites with ${missedPaths.length} missed paths'); + } + }); + } } @immutable diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index ca9f09623..2dfbe54ec 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -231,7 +231,7 @@ class SqfliteMetadataDb implements MetadataDb { Future> loadAllEntries() async { final db = await _database; final maps = await db.query(entryTable); - final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); + final entries = maps.map(AvesEntry.fromMap).toSet(); return entries; } @@ -273,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb { orderBy: 'sourceDateTakenMillis DESC', limit: limit, ); - return maps.map((map) => AvesEntry.fromMap(map)).toSet(); + return maps.map(AvesEntry.fromMap).toSet(); } // date taken @@ -306,7 +306,7 @@ class SqfliteMetadataDb implements MetadataDb { Future> loadAllMetadataEntries() async { final db = await _database; final maps = await db.query(metadataTable); - final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); + final metadataEntries = maps.map(CatalogMetadata.fromMap).toList(); return metadataEntries; } @@ -367,7 +367,7 @@ class SqfliteMetadataDb implements MetadataDb { Future> loadAllAddresses() async { final db = await _database; final maps = await db.query(addressTable); - final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList(); + final addresses = maps.map(AddressDetails.fromMap).toList(); return addresses; } @@ -413,7 +413,7 @@ class SqfliteMetadataDb implements MetadataDb { Future> loadAllFavourites() async { final db = await _database; final maps = await db.query(favouriteTable); - final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet(); + final rows = maps.map(FavouriteRow.fromMap).toSet(); return rows; } diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 08e3a888c..7593a4c0b 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -36,7 +36,7 @@ class MultiPageInfo { factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List pageMaps) { return MultiPageInfo( mainEntry: mainEntry, - pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(), + pages: pageMaps.map(SinglePageInfo.fromMap).toList(), ); } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 707804c4d..2740db8fd 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'package:aves/l10n/l10n.dart'; @@ -570,12 +569,11 @@ class Settings extends ChangeNotifier { // import/export - String toJson() => jsonEncode(Map.fromEntries( + Map export() => Map.fromEntries( _prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))), - )); + ); - Future fromJson(String jsonString) async { - final jsonMap = jsonDecode(jsonString); + Future import(dynamic jsonMap) async { if (jsonMap is Map) { // clear to restore defaults await reset(includeInternalKeys: false); diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index e39413950..6035caa19 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -8,4 +8,4 @@ enum EntrySortFactor { date, name, rating, size } enum EntryGroupFactor { none, album, month, day } -enum TileLayout { grid, list } \ No newline at end of file +enum TileLayout { grid, list } diff --git a/lib/ref/iptc.dart b/lib/ref/iptc.dart index 908f18914..8e88eea70 100644 --- a/lib/ref/iptc.dart +++ b/lib/ref/iptc.dart @@ -3,4 +3,4 @@ class IPTC { // ApplicationRecord tags static const int keywordsTag = 25; -} \ No newline at end of file +} diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index fa47d60ed..ee0366e31 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -38,7 +38,7 @@ class PlatformAndroidAppService implements AndroidAppService { Future> getPackages() async { try { final result = await platform.invokeMethod('getPackages'); - final packages = (result as List).cast().map((map) => Package.fromMap(map)).toSet(); + final packages = (result as List).cast().map(Package.fromMap).toSet(); // additional info for known directories final kakaoTalk = packages.firstWhereOrNull((package) => package.packageName == 'com.kakao.talk'); if (kakaoTalk != null) { diff --git a/lib/services/common/service_policy.dart b/lib/services/common/service_policy.dart index 0406f4f29..c8fe1f09b 100644 --- a/lib/services/common/service_policy.dart +++ b/lib/services/common/service_policy.dart @@ -66,7 +66,7 @@ class ServicePolicy { } } - LinkedHashMap _getQueue(int priority) => _queues.putIfAbsent(priority, () => LinkedHashMap()); + LinkedHashMap _getQueue(int priority) => _queues.putIfAbsent(priority, LinkedHashMap.new); void _pickNext() { _notifyQueueState(); diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index 795571bfb..3a877b3d8 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -32,18 +32,18 @@ final StorageService storageService = getIt(); final WindowService windowService = getIt(); void initPlatformServices() { - getIt.registerLazySingleton(() => p.Context()); - getIt.registerLazySingleton(() => LiveAvesAvailability()); - getIt.registerLazySingleton(() => SqfliteMetadataDb()); + getIt.registerLazySingleton(p.Context.new); + getIt.registerLazySingleton(LiveAvesAvailability.new); + getIt.registerLazySingleton(SqfliteMetadataDb.new); - getIt.registerLazySingleton(() => PlatformAndroidAppService()); - getIt.registerLazySingleton(() => PlatformDeviceService()); - getIt.registerLazySingleton(() => PlatformEmbeddedDataService()); - getIt.registerLazySingleton(() => PlatformMediaFileService()); - getIt.registerLazySingleton(() => PlatformMediaStoreService()); - getIt.registerLazySingleton(() => PlatformMetadataEditService()); - getIt.registerLazySingleton(() => PlatformMetadataFetchService()); - getIt.registerLazySingleton(() => PlatformReportService()); - getIt.registerLazySingleton(() => PlatformStorageService()); - getIt.registerLazySingleton(() => PlatformWindowService()); + getIt.registerLazySingleton(PlatformAndroidAppService.new); + getIt.registerLazySingleton(PlatformDeviceService.new); + getIt.registerLazySingleton(PlatformEmbeddedDataService.new); + getIt.registerLazySingleton(PlatformMediaFileService.new); + getIt.registerLazySingleton(PlatformMediaStoreService.new); + getIt.registerLazySingleton(PlatformMetadataEditService.new); + getIt.registerLazySingleton(PlatformMetadataFetchService.new); + getIt.registerLazySingleton(PlatformReportService.new); + getIt.registerLazySingleton(PlatformStorageService.new); + getIt.registerLazySingleton(PlatformWindowService.new); } diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index 09a9f4d96..28f6990d3 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -20,7 +20,7 @@ class GeocodingService { // returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+ 'maxResults': 2, }); - return (result as List).cast().map((map) => Address.fromMap(map)).toList(); + return (result as List).cast().map(Address.fromMap).toList(); } on PlatformException catch (e, stack) { if (e.code != 'getAddress-empty' && e.code != 'getAddress-network') { await reportService.recordError(e, stack); diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 9c65a6c44..8b7f9240d 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -325,11 +325,14 @@ class PlatformMediaFileService implements MediaFileService { required Iterable entries, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'delete', - 'id': opId, - 'entries': entries.map(_toPlatformEntryMap).toList(), - }).map((event) => ImageOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'delete', + 'id': opId, + 'entries': entries.map(_toPlatformEntryMap).toList(), + }) + .where((event) => event is Map) + .map((event) => ImageOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -345,14 +348,17 @@ class PlatformMediaFileService implements MediaFileService { required NameConflictStrategy nameConflictStrategy, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'move', - 'id': opId, - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'copy': copy, - 'destinationPath': destinationAlbum, - 'nameConflictStrategy': nameConflictStrategy.toPlatform(), - }).map((event) => MoveOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'move', + 'id': opId, + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'copy': copy, + 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), + }) + .where((event) => event is Map) + .map((event) => MoveOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -367,13 +373,16 @@ class PlatformMediaFileService implements MediaFileService { required NameConflictStrategy nameConflictStrategy, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'export', - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'mimeType': mimeType, - 'destinationPath': destinationAlbum, - 'nameConflictStrategy': nameConflictStrategy.toPlatform(), - }).map((event) => ExportOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'export', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'mimeType': mimeType, + 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), + }) + .where((event) => event is Map) + .map((event) => ExportOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -386,11 +395,14 @@ class PlatformMediaFileService implements MediaFileService { required String newName, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'rename', - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'newName': newName, - }).map((event) => MoveOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'rename', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'newName': newName, + }) + .where((event) => event is Map) + .map((event) => MoveOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index 67ccb7b5a..9bb493e92 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -50,9 +50,12 @@ class PlatformMediaStoreService implements MediaStoreService { @override Stream getEntries(Map knownEntries) { try { - return _streamChannel.receiveBroadcastStream({ - 'knownEntries': knownEntries, - }).map((event) => AvesEntry.fromMap(event)); + return _streamChannel + .receiveBroadcastStream({ + 'knownEntries': knownEntries, + }) + .where((event) => event is Map) + .map((event) => AvesEntry.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a1fc9bd36..003deb434 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -47,7 +47,7 @@ class PlatformStorageService implements StorageService { Future> getStorageVolumes() async { try { final result = await platform.invokeMethod('getStorageVolumes'); - return (result as List).cast().map((map) => StorageVolume.fromMap(map)).toSet(); + return (result as List).cast().map(StorageVolume.fromMap).toSet(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index adc11af73..abce6c9dd 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -65,7 +65,7 @@ class MonthSectionHeader extends StatelessWidget { if (date == null) return l10n.sectionUnknown; if (date.isThisMonth) return l10n.dateThisMonth; final locale = l10n.localeName; - final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date); + final localized = date.isThisYear ? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date); return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}'; } diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index dc5156dcd..0a933970d 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -320,7 +320,7 @@ class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterCh filter: filter, useFilterColor: false, maxWidth: double.infinity, - onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne) ).dispatch(context), + onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne)).dispatch(context), ), ), ); diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 790dd17e6..1dbdc21d2 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -293,7 +293,7 @@ class _GeoMapState extends State { // node size: 64 by default, higher means faster indexing but slower search nodeSize: nodeSize, points: markers, - createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), + createCluster: GeoEntry.createCluster, ); } diff --git a/lib/widgets/debug/media_store_scan_dialog.dart b/lib/widgets/debug/media_store_scan_dialog.dart index 6c07e3c86..f211c652e 100644 --- a/lib/widgets/debug/media_store_scan_dialog.dart +++ b/lib/widgets/debug/media_store_scan_dialog.dart @@ -4,7 +4,6 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; class MediaStoreScanDirDialog extends StatefulWidget { const MediaStoreScanDirDialog({Key? key}) : super(key: key); @@ -37,7 +36,7 @@ class _MediaStoreScanDirDialogState extends State { setState(() => _processing = true); await Future.forEach(Directory(dir).listSync(recursive: true), (file) async { if (file is File) { - final mimeType = MimeTypes.forExtension(p.extension(file.path)); + final mimeType = MimeTypes.forExtension(pContext.extension(file.path)); await mediaStoreService.scanFile(file.path, mimeType!); } }); diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index ff5295e3f..e6f70dda0 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -57,7 +57,7 @@ class _AvesSelectionDialogState extends State> { if (needConfirmation) TextButton( onPressed: () => Navigator.pop(context, _selectedValue), - child: Text(confirmationButtonLabel!), + child: Text(confirmationButtonLabel), ), ], ); diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart index d3f7ea85e..49dbe5af8 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -77,8 +77,8 @@ class _TagEditorPageState extends State { builder: (context, value, child) { final upQuery = value.text.trim().toUpperCase(); bool containQuery(String s) => s.toUpperCase().contains(upQuery); - final recentFilters = _recentTags.where(containQuery).map((v) => TagFilter(v)).toList(); - final topTagFilters = _topTags.where(containQuery).map((v) => TagFilter(v)).toList(); + final recentFilters = _recentTags.where(containQuery).map(TagFilter.new).toList(); + final topTagFilters = _topTags.where(containQuery).map(TagFilter.new).toList(); return ListView( children: [ Padding( diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index 1db0991e1..119d499e7 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -54,7 +54,7 @@ class FilterListDetails extends StatelessWidget { padding: const EdgeInsets.only(right: FilterListDetailsTheme.titleIconPadding), child: IconTheme( data: IconThemeData(color: detailsTheme.titleStyle.color), - child: leading!, + child: leading, ), ), ), diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 65114973c..92220bbc8 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -53,7 +53,7 @@ class TagListPage extends StatelessWidget { } List> _getGridItems(CollectionSource source) { - final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet(); + final filters = source.sortedTags.map(TagFilter.new).toSet(); return FilterNavigationPage.sort(settings.tagSortFactor, source, filters); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index e66b1c43b..e426f5799 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -171,7 +171,7 @@ class CollectionSearchDelegate { StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { - final filters = source.sortedTags.where(containQuery).map((s) => TagFilter(s)); + final filters = source.sortedTags.where(containQuery).map(TagFilter.new); final noFilter = TagFilter(''); return _buildFilterRow( context: context, @@ -185,7 +185,7 @@ class CollectionSearchDelegate { _buildFilterRow( context: context, title: context.l10n.searchSectionRating, - filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).where((f) => containQuery(f.getLabel(context))).toList(), + filters: [0, 5, 4, 3, 2, 1, -1].map(RatingFilter.new).where((f) => containQuery(f.getLabel(context))).toList(), ), ], ); diff --git a/lib/widgets/settings/app_export/items.dart b/lib/widgets/settings/app_export/items.dart new file mode 100644 index 000000000..3a94614b4 --- /dev/null +++ b/lib/widgets/settings/app_export/items.dart @@ -0,0 +1,46 @@ +import 'package:aves/model/covers.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +enum AppExportItem { covers, favourites, settings } + +extension ExtraAppExportItem on AppExportItem { + String getText(BuildContext context) { + switch (this) { + case AppExportItem.covers: + return context.l10n.appExportCovers; + case AppExportItem.favourites: + return context.l10n.appExportFavourites; + case AppExportItem.settings: + return context.l10n.appExportSettings; + } + } + + dynamic export(CollectionSource source) { + switch (this) { + case AppExportItem.covers: + return covers.export(source); + case AppExportItem.favourites: + return favourites.export(source); + case AppExportItem.settings: + return settings.export(); + } + } + + Future import(dynamic jsonMap, CollectionSource source) async { + switch (this) { + case AppExportItem.covers: + covers.import(jsonMap, source); + break; + case AppExportItem.favourites: + favourites.import(jsonMap, source); + break; + case AppExportItem.settings: + await settings.import(jsonMap); + break; + } + } +} diff --git a/lib/widgets/settings/app_export/selection_dialog.dart b/lib/widgets/settings/app_export/selection_dialog.dart new file mode 100644 index 000000000..a02d0a311 --- /dev/null +++ b/lib/widgets/settings/app_export/selection_dialog.dart @@ -0,0 +1,68 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/settings/app_export/items.dart'; +import 'package:flutter/material.dart'; + +class AppExportItemSelectionDialog extends StatefulWidget { + final String title; + final Set? selectableItems, initialSelection; + + const AppExportItemSelectionDialog({ + Key? key, + required this.title, + this.selectableItems, + this.initialSelection, + }) : super(key: key); + + @override + _AppExportItemSelectionDialogState createState() => _AppExportItemSelectionDialogState(); +} + +class _AppExportItemSelectionDialogState extends State { + final Set _selectableItems = {}, _selectedItems = {}; + + @override + void initState() { + super.initState(); + _selectableItems.addAll(widget.selectableItems ?? AppExportItem.values); + _selectedItems.addAll(widget.initialSelection ?? _selectableItems); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + title: widget.title, + scrollableContent: AppExportItem.values.map((v) { + return SwitchListTile( + value: _selectedItems.contains(v), + onChanged: _selectableItems.contains(v) + ? (selected) { + if (selected == true) { + _selectedItems.add(v); + } else { + _selectedItems.remove(v); + } + setState(() {}); + } + : null, + title: Text( + v.getText(context), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + }).toList(), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: _selectedItems.isEmpty ? null : () => Navigator.pop(context, _selectedItems), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart index 7889ce085..83da70732 100644 --- a/lib/widgets/settings/privacy/file_picker/crumb_line.dart +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -1,7 +1,7 @@ +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; class CrumbLine extends StatefulWidget { final VolumeRelativeDirectory directory; @@ -42,7 +42,7 @@ class _CrumbLineState extends State { Widget build(BuildContext context) { List parts = [ directory.getVolumeDescription(context), - ...p.split(directory.relativeDir), + ...pContext.split(directory.relativeDir), ]; final crumbStyle = Theme.of(context).textTheme.bodyText2; final crumbColor = crumbStyle!.color!.withOpacity(.4); @@ -76,7 +76,7 @@ class _CrumbLineState extends State { } return GestureDetector( onTap: () { - final path = p.joinAll([ + final path = pContext.joinAll([ directory.volumePath, ...parts.skip(1).take(index), ]); diff --git a/lib/widgets/settings/privacy/file_picker/file_picker.dart b/lib/widgets/settings/privacy/file_picker/file_picker.dart index 7e7afcd71..6930835c5 100644 --- a/lib/widgets/settings/privacy/file_picker/file_picker.dart +++ b/lib/widgets/settings/privacy/file_picker/file_picker.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -14,7 +15,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:path/path.dart' as p; class FilePicker extends StatefulWidget { static const routeName = '/file_picker'; @@ -31,7 +31,7 @@ class _FilePickerState extends State { Set get volumes => androidFileUtils.storageVolumes; - String get currentDirectoryPath => p.join(_directory.volumePath, _directory.relativeDir); + String get currentDirectoryPath => pContext.join(_directory.volumePath, _directory.relativeDir); @override void initState() { @@ -48,7 +48,7 @@ class _FilePickerState extends State { if (showHidden) { return true; } else { - final isHidden = p.split(v.path).last.startsWith('.'); + final isHidden = pContext.split(v.path).last.startsWith('.'); return !isHidden; } }).toList(); @@ -57,7 +57,7 @@ class _FilePickerState extends State { if (_directory.relativeDir.isEmpty) { return SynchronousFuture(true); } - final parent = p.dirname(currentDirectoryPath); + final parent = pContext.dirname(currentDirectoryPath); _goTo(parent); setState(() {}); return SynchronousFuture(false); @@ -143,7 +143,7 @@ class _FilePickerState extends State { if (_directory.relativeDir.isEmpty) { return _directory.getVolumeDescription(context); } - return p.split(_directory.relativeDir).last; + return pContext.split(_directory.relativeDir).last; } Widget _buildDrawer(BuildContext context) { @@ -179,7 +179,7 @@ class _FilePickerState extends State { Widget _buildContentLine(BuildContext context, FileSystemEntity content) { return ListTile( leading: const Icon(AIcons.folder), - title: Text(p.split(content.path).last), + title: Text(pContext.split(content.path).last), onTap: () { _goTo(content.path); setState(() {}); @@ -197,7 +197,7 @@ class _FilePickerState extends State { contents.add(entity); } }, onDone: () { - _contents = contents..sort((a, b) => compareAsciiUpperCase(p.split(a.path).last, p.split(b.path).last)); + _contents = contents..sort((a, b) => compareAsciiUpperCase(pContext.split(a.path).last, pContext.split(b.path).last)); setState(() {}); }); } diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index e0cef8ed7..b55e3f81a 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:aves/model/actions/settings_actions.dart'; -import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; @@ -12,12 +12,15 @@ import 'package:aves/widgets/common/basic/menu.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/settings/accessibility/accessibility.dart'; +import 'package:aves/widgets/settings/app_export/items.dart'; +import 'package:aves/widgets/settings/app_export/selection_dialog.dart'; import 'package:aves/widgets/settings/language/language.dart'; import 'package:aves/widgets/settings/navigation/navigation.dart'; import 'package:aves/widgets/settings/privacy/privacy.dart'; import 'package:aves/widgets/settings/thumbnails/thumbnails.dart'; import 'package:aves/widgets/settings/video/video.dart'; import 'package:aves/widgets/settings/viewer/viewer.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; @@ -106,13 +109,32 @@ class _SettingsPageState extends State with FeedbackMixin { ); } + static const String exportVersionKey = 'version'; + static const int exportVersion = 1; + void _onActionSelected(SettingsAction action) async { + final source = context.read(); switch (action) { case SettingsAction.export: + final toExport = await showDialog>( + context: context, + builder: (context) => AppExportItemSelectionDialog( + title: context.l10n.settingsActionExport, + ), + ); + if (toExport == null || toExport.isEmpty) return; + + final allMap = Map.fromEntries(toExport.map((v) { + final jsonMap = v.export(source); + return jsonMap != null ? MapEntry(v.name, jsonMap) : null; + }).whereNotNull()); + allMap[exportVersionKey] = exportVersion; + final allJsonString = jsonEncode(allMap); + final success = await storageService.createFile( 'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json', MimeTypes.json, - Uint8List.fromList(utf8.encode(settings.toJson())), + Uint8List.fromList(utf8.encode(allJsonString)), ); if (success != null) { if (success) { @@ -128,10 +150,44 @@ class _SettingsPageState extends State with FeedbackMixin { final bytes = await storageService.openFile(); if (bytes.isNotEmpty) { try { - await settings.fromJson(utf8.decode(bytes)); + final allJsonString = utf8.decode(bytes); + final allJsonMap = jsonDecode(allJsonString); + + final version = allJsonMap[exportVersionKey]; + final importable = {}; + if (version == null) { + // backwards compatibility before versioning + importable[AppExportItem.settings] = allJsonMap; + } else { + if (allJsonMap is! Map) { + debugPrint('failed to import app json=$allJsonMap'); + showFeedback(context, context.l10n.genericFailureFeedback); + return; + } + allJsonMap.keys.where((v) => v != exportVersionKey).forEach((k) { + try { + importable[AppExportItem.values.byName(k)] = allJsonMap[k]; + } catch (error, stack) { + debugPrint('failed to identify import app item=$k with error=$error\n$stack'); + } + }); + } + + final toImport = await showDialog>( + context: context, + builder: (context) => AppExportItemSelectionDialog( + title: context.l10n.settingsActionImport, + selectableItems: importable.keys.toSet(), + ), + ); + if (toImport == null || toImport.isEmpty) return; + + await Future.forEach(toImport, (item) async { + return item.import(importable[item], source); + }); showFeedback(context, context.l10n.genericSuccessFeedback); } catch (error) { - debugPrint('failed to import settings, error=$error'); + debugPrint('failed to import app json, error=$error'); showFeedback(context, context.l10n.genericFailureFeedback); } } diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 764d4f389..59312765e 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -133,8 +133,8 @@ class StatsPage extends StatelessWidget { locationIndicator, ..._buildFilterSection(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), ..._buildFilterSection(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), - ..._buildFilterSection(context, context.l10n.statsTopTags, entryCountPerTag, (v) => TagFilter(v)), - if (showRatings) ..._buildFilterSection(context, context.l10n.searchSectionRating, entryCountPerRating, (v) => RatingFilter(v), sortByCount: false, maxRowCount: null), + ..._buildFilterSection(context, context.l10n.statsTopTags, entryCountPerTag, TagFilter.new), + if (showRatings) ..._buildFilterSection(context, context.l10n.searchSectionRating, entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), ], ); } diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 70556e422..a750d91b7 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -96,7 +96,7 @@ class BasicSection extends StatelessWidget { if (entry.isVideo && !entry.is360) MimeFilter.video, if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)), if (entry.rating != 0) RatingFilter(entry.rating), - ...tags.map((tag) => TagFilter(tag)), + ...tags.map(TagFilter.new), }; return AnimatedBuilder( animation: favourites, diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 61300cc58..f04f0f164 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -162,6 +162,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // `enable-accurate-seek`: enable accurate seek // default: 0, in [0, 1] + // ignore: dead_code options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0); // `min-frames`: minimal frames to stop pre-reading diff --git a/pubspec.yaml b/pubspec.yaml index af78461c3..81bd74881 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ version: 1.5.10+64 publish_to: none environment: - sdk: '>=2.14.0 <3.0.0' + sdk: '>=2.15.0 <3.0.0' # use `scripts/apply_flavor_{flavor}.sh` to set the right dependencies for the flavor dependencies: diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 914c82ead..b641e886a 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -52,17 +52,17 @@ void main() { setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); - getIt.registerLazySingleton(() => FakeAvesAvailability()); - getIt.registerLazySingleton(() => FakeMetadataDb()); + getIt.registerLazySingleton(FakeAvesAvailability.new); + getIt.registerLazySingleton(FakeMetadataDb.new); - getIt.registerLazySingleton(() => FakeAndroidAppService()); - getIt.registerLazySingleton(() => FakeDeviceService()); - getIt.registerLazySingleton(() => FakeMediaFileService()); - getIt.registerLazySingleton(() => FakeMediaStoreService()); - getIt.registerLazySingleton(() => FakeMetadataFetchService()); - getIt.registerLazySingleton(() => FakeReportService()); - getIt.registerLazySingleton(() => FakeStorageService()); - getIt.registerLazySingleton(() => FakeWindowService()); + getIt.registerLazySingleton(FakeAndroidAppService.new); + getIt.registerLazySingleton(FakeDeviceService.new); + getIt.registerLazySingleton(FakeMediaFileService.new); + getIt.registerLazySingleton(FakeMediaStoreService.new); + getIt.registerLazySingleton(FakeMetadataFetchService.new); + getIt.registerLazySingleton(FakeReportService.new); + getIt.registerLazySingleton(FakeStorageService.new); + getIt.registerLazySingleton(FakeWindowService.new); await settings.init(monitorPlatformSettings: false); settings.canUseAnalysisService = false; diff --git a/test/utils/android_file_utils.dart b/test/utils/android_file_utils.dart index 9d05a79c8..581acb9ab 100644 --- a/test/utils/android_file_utils.dart +++ b/test/utils/android_file_utils.dart @@ -11,7 +11,7 @@ void main() { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); - getIt.registerLazySingleton(() => FakeStorageService()); + getIt.registerLazySingleton(FakeStorageService.new); await androidFileUtils.init(); }); diff --git a/untranslated.json b/untranslated.json index 3b8f080bb..2b6e80ddd 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,6 @@ { "ru": [ + "appExportCovers", "settingsThumbnailShowFavouriteIcon" ] }