#52 hidden paths

This commit is contained in:
Thibault Deckers 2021-07-13 09:33:32 +09:00
parent 97e3063998
commit 1f7e70697e
12 changed files with 242 additions and 6 deletions

View file

@ -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<Int, PendingResultHandler>()

View file

@ -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?) {

View file

@ -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",

View file

@ -313,6 +313,13 @@
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
"settingsHiddenPathsTile": "숨겨진 경로",
"settingsHiddenPathsTitle": "숨겨진 경로",
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
"settingsHiddenPathsRemoveTooltip": "제거",
"addPathTooltip": "경로 추가",
"settingsStorageAccessTile": "저장공간 접근",
"settingsStorageAccessTitle": "저장공간 접근",
"settingsStorageAccessBanner": "어떤 폴더는 사용자의 허용을 받아야만 앱이 파일에 접근이 가능합니다. 이 화면에 허용을 받은 폴더를 확인할 수 있으며 원하지 않으면 취소할 수 있습니다.",

View file

@ -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<CollectionFilter> {
AlbumFilter.type,
LocationFilter.type,
TagFilter.type,
PathFilter.type,
];
static CollectionFilter? fromJson(String jsonString) {
@ -43,6 +45,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
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<CollectionFilter> {
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> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));

View file

@ -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<String, dynamic> json)
: this(
json['path'],
);
@override
Map<String, dynamic> 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}';
}

View file

@ -33,6 +33,8 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
}
class PlatformStorageService implements StorageService {
@ -217,4 +219,25 @@ class PlatformStorageService implements StorageService {
}
return Uint8List(0);
}
@override
Future<String?> selectDirectory() async {
try {
final completer = Completer<String>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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;
}
}

View file

@ -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;

View file

@ -77,7 +77,7 @@ class SectionHeader<T> extends StatelessWidget {
}
void _toggleSectionSelection(BuildContext context) {
final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey]!;
final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
final selection = context.read<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
if (isSelected) {
@ -189,7 +189,7 @@ class _SectionSelectingLeading<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey]!;
final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey] ?? [];
final selection = context.watch<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
return AnimatedSwitcher(

View file

@ -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<Settings, Set<CollectionFilter>>(
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(

View file

@ -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<CollectionSource>().changeFilterVisibility({PathFilter(path)}, false);
}
},
tooltip: context.l10n.addPathTooltip,
),
],
),
body: SafeArea(
child: Selector<Settings, Set<PathFilter>>(
selector: (context, s) => settings.hiddenFilters.whereType<PathFilter>().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<CollectionSource>().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)),
],
),
);
}
}

View file

@ -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(),
],
);