diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 2e3f4e14a..824bce92c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -114,7 +114,7 @@ class MainActivity : FlutterActivity() { MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) } } - CREATE_FILE_REQUEST, OPEN_FILE_REQUEST -> { + CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> { onPermissionResult(requestCode, data?.data) } } @@ -196,6 +196,7 @@ class MainActivity : FlutterActivity() { const val DELETE_PERMISSION_REQUEST = 2 const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 + const val SELECT_DIRECTORY_REQUEST = 5 // permission request code to pending runnable val pendingResultHandlers = ConcurrentHashMap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 3937e6683..0434ceaab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -10,6 +10,7 @@ import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingResultHandler import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager +import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers @@ -41,6 +42,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? "requestVolumeAccess" -> requestVolumeAccess() "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } + "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } else -> endOfStream() } } @@ -128,6 +130,24 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } + private fun selectDirectory() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + MainActivity.pendingResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingResultHandler(null, { uri -> + success(StorageUtils.convertTreeUriToDirPath(activity, uri)) + endOfStream() + }, { + success(null) + endOfStream() + }) + activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST) + } else { + success(null) + endOfStream() + } + } + override fun onCancel(arguments: Any?) {} private fun success(result: Any?) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 365806c02..c488f00e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -659,6 +659,19 @@ "settingsHiddenFiltersEmpty": "No hidden filters", "@settingsHiddenFiltersEmpty": {}, + "settingsHiddenPathsTile": "Hidden paths", + "@settingsHiddenPathsTile": {}, + "settingsHiddenPathsTitle": "Hidden Paths", + "@settingsHiddenPathsTitle": {}, + "settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.", + "@settingsHiddenPathsBanner": {}, + "settingsHiddenPathsEmpty": "No hidden paths", + "@settingsHiddenPathsEmpty": {}, + "settingsHiddenPathsRemoveTooltip": "Remove", + "@settingsHiddenPathsRemoveTooltip": {}, + "addPathTooltip": "Add path", + "@addPathTooltip": {}, + "settingsStorageAccessTile": "Storage access", "@settingsStorageAccessTile": {}, "settingsStorageAccessTitle": "Storage Access", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c74d30292..44f353772 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -313,6 +313,13 @@ "settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", "settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다", + "settingsHiddenPathsTile": "숨겨진 경로", + "settingsHiddenPathsTitle": "숨겨진 경로", + "settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", + "settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다", + "settingsHiddenPathsRemoveTooltip": "제거", + "addPathTooltip": "경로 추가", + "settingsStorageAccessTile": "저장공간 접근", "settingsStorageAccessTitle": "저장공간 접근", "settingsStorageAccessBanner": "어떤 폴더는 사용자의 허용을 받아야만 앱이 파일에 접근이 가능합니다. 이 화면에 허용을 받은 폴더를 확인할 수 있으며 원하지 않으면 취소할 수 있습니다.", diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 3c141385d..33d9c9952 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; @@ -22,6 +23,7 @@ abstract class CollectionFilter implements Comparable { AlbumFilter.type, LocationFilter.type, TagFilter.type, + PathFilter.type, ]; static CollectionFilter? fromJson(String jsonString) { @@ -43,6 +45,8 @@ abstract class CollectionFilter implements Comparable { return QueryFilter.fromMap(jsonMap); case TagFilter.type: return TagFilter.fromMap(jsonMap); + case PathFilter.type: + return PathFilter.fromMap(jsonMap); } } debugPrint('failed to parse filter from json=$jsonString'); @@ -65,7 +69,7 @@ abstract class CollectionFilter implements Comparable { String getTooltip(BuildContext context) => getLabel(context); - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}); + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => null; Future color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context))); diff --git a/lib/model/filters/path.dart b/lib/model/filters/path.dart new file mode 100644 index 000000000..f4708579d --- /dev/null +++ b/lib/model/filters/path.dart @@ -0,0 +1,46 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class PathFilter extends CollectionFilter { + static const type = 'path'; + + final String path; + + const PathFilter(this.path); + + PathFilter.fromMap(Map json) + : this( + json['path'], + ); + + @override + Map toMap() => { + 'type': type, + 'path': path, + }; + + @override + EntryFilter get test => (entry) => entry.directory?.startsWith(path) ?? false; + + @override + String get universalLabel => path; + + @override + String get category => type; + + @override + String get key => '$type-$path'; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is PathFilter && other.path == path; + } + + @override + int get hashCode => hashValues(type, path); + + @override + String toString() => '$runtimeType#${shortHash(this)}{path=$path}'; +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 107a89108..8e1574ccc 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -33,6 +33,8 @@ abstract class StorageService { Future createFile(String name, String mimeType, Uint8List bytes); Future openFile(String mimeType); + + Future selectDirectory(); } class PlatformStorageService implements StorageService { @@ -217,4 +219,25 @@ class PlatformStorageService implements StorageService { } return Uint8List(0); } + + @override + Future selectDirectory() async { + try { + final completer = Completer(); + storageAccessChannel.receiveBroadcastStream({ + 'op': 'selectDirectory', + }).listen( + (data) => completer.complete(data as String?), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete(null); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e) { + debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return null; + } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 0e6b2a0b7..5b5faa1ce 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -30,6 +30,7 @@ class AIcons { static const IconData tagOff = MdiIcons.tagOffOutline; // actions + static const IconData addPath = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 6d764ee22..9d1b9c356 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -77,7 +77,7 @@ class SectionHeader extends StatelessWidget { } void _toggleSectionSelection(BuildContext context) { - final sectionEntries = context.read>().sections[sectionKey]!; + final sectionEntries = context.read>().sections[sectionKey] ?? []; final selection = context.read>(); final isSelected = selection.isSelected(sectionEntries); if (isSelected) { @@ -189,7 +189,7 @@ class _SectionSelectingLeading extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionEntries = context.watch>().sections[sectionKey]!; + final sectionEntries = context.watch>().sections[sectionKey] ?? []; final selection = context.watch>(); final isSelected = selection.isSelected(sectionEntries); return AnimatedSwitcher( diff --git a/lib/widgets/settings/privacy/hidden_filters.dart b/lib/widgets/settings/privacy/hidden_filters.dart index 619cf61b2..525b99768 100644 --- a/lib/widgets/settings/privacy/hidden_filters.dart +++ b/lib/widgets/settings/privacy/hidden_filters.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/path.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; @@ -29,7 +30,7 @@ class HiddenFilterTile extends StatelessWidget { } class HiddenFilterPage extends StatelessWidget { - static const routeName = '/settings/hidden'; + static const routeName = '/settings/hidden_filters'; const HiddenFilterPage({Key? key}) : super(key: key); @@ -41,7 +42,7 @@ class HiddenFilterPage extends StatelessWidget { ), body: SafeArea( child: Selector>( - selector: (context, s) => settings.hiddenFilters, + selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(), builder: (context, hiddenFilters, child) { if (hiddenFilters.isEmpty) { return Column( diff --git a/lib/widgets/settings/privacy/hidden_paths.dart b/lib/widgets/settings/privacy/hidden_paths.dart new file mode 100644 index 000000000..5da86e9af --- /dev/null +++ b/lib/widgets/settings/privacy/hidden_paths.dart @@ -0,0 +1,118 @@ +import 'package:aves/model/filters/path.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class HiddenPathTile extends StatelessWidget { + const HiddenPathTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsHiddenPathsTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: HiddenPathPage.routeName), + builder: (context) => const HiddenPathPage(), + ), + ); + }, + ); + } +} + +class HiddenPathPage extends StatelessWidget { + static const routeName = '/settings/hidden_paths'; + + const HiddenPathPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsHiddenPathsTitle), + actions: [ + IconButton( + icon: const Icon(AIcons.addPath), + onPressed: () async { + final path = await storageService.selectDirectory(); + if (path != null && path.isNotEmpty) { + context.read().changeFilterVisibility({PathFilter(path)}, false); + } + }, + tooltip: context.l10n.addPathTooltip, + ), + ], + ), + body: SafeArea( + child: Selector>( + selector: (context, s) => settings.hiddenFilters.whereType().toSet(), + builder: (context, hiddenPaths, child) { + if (hiddenPaths.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Header(), + const Divider(), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: EmptyContent( + icon: AIcons.hide, + text: context.l10n.settingsHiddenPathsEmpty, + ), + ), + ), + ], + ); + } + + final pathList = hiddenPaths.toList()..sort(); + return ListView( + children: [ + const _Header(), + const Divider(), + ...pathList.map((pathFilter) => ListTile( + title: Text(pathFilter.path), + dense: true, + trailing: IconButton( + icon: const Icon(AIcons.clear), + onPressed: () { + context.read().changeFilterVisibility({pathFilter}, true); + }, + tooltip: context.l10n.settingsHiddenPathsRemoveTooltip, + ), + )), + ], + ); + }, + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + const Icon(AIcons.info), + const SizedBox(width: 16), + Expanded(child: Text(context.l10n.settingsHiddenPathsBanner)), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 38bb252a1..c850a92bd 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/privacy/access_grants.dart'; import 'package:aves/widgets/settings/privacy/hidden_filters.dart'; +import 'package:aves/widgets/settings/privacy/hidden_paths.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -47,6 +48,7 @@ class PrivacySection extends StatelessWidget { title: Text(context.l10n.settingsSaveSearchHistory), ), const HiddenFilterTile(), + const HiddenPathTile(), const StorageAccessTile(), ], );