#136 hidden paths: select directory with custom picker instead of SAF one

This commit is contained in:
Thibault Deckers 2021-11-27 16:58:36 +09:00
parent b837c0a5b6
commit db78210a37
13 changed files with 373 additions and 55 deletions

View file

@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST,
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
}
}
@ -290,9 +289,8 @@ class MainActivity : FlutterActivity() {
const val OPEN_FROM_ANALYSIS_SERVICE = 2
const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5
const val DELETE_SINGLE_PERMISSION_REQUEST = 6
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_FILTERS = "filters"

View file

@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
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
@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream()
}
}
@ -158,25 +156,6 @@ 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.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
endOfStream()
}, {
success(null)
endOfStream()
})
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
} else {
// TODO TLAD support KitKat
success(null)
endOfStream()
}
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) {

View file

@ -1064,5 +1064,16 @@
"@panoramaDisableSensorControl": {},
"sourceViewerPageTitle": "Source",
"@sourceViewerPageTitle": {}
"@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Show hidden files",
"@filePickerShowHiddenFiles": {},
"filePickerDoNotShowHiddenFiles": "Dont show hidden files",
"@filePickerDoNotShowHiddenFiles": {},
"filePickerOpenFrom": "Open from",
"@filePickerOpenFrom": {},
"filePickerNoItems": "No items",
"@filePickerNoItems": {},
"filePickerUseThisFolder": "Use this folder",
"@filePickerUseThisFolder": {}
}

View file

@ -513,5 +513,11 @@
"panoramaDisableSensorControl": "Désactiver le contrôle par capteurs",
"sourceViewerPageTitle": "Code source",
"@sourceViewerPageTitle": {}
"@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Afficher les fichiers masqués",
"filePickerDoNotShowHiddenFiles": "Ne pas afficher les fichiers masqués",
"filePickerOpenFrom": "Ouvrir à partir de",
"filePickerNoItems": "Aucun élément",
"filePickerUseThisFolder": "Utiliser ce dossier"
}

View file

@ -508,5 +508,11 @@
"panoramaEnableSensorControl": "센서 제어 활성화",
"panoramaDisableSensorControl": "센서 제어 비활성화",
"sourceViewerPageTitle": "소스 코드"
"sourceViewerPageTitle": "소스 코드",
"filePickerShowHiddenFiles": "숨겨진 파일 표시",
"filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함",
"filePickerOpenFrom": "다음에서 열기:",
"filePickerNoItems": "항목 없음",
"filePickerUseThisFolder": "이 폴더 사용"
}

View file

@ -503,5 +503,11 @@
"panoramaEnableSensorControl": "Включить сенсорное управление",
"panoramaDisableSensorControl": "Отключить сенсорное управление",
"sourceViewerPageTitle": "Источник"
"sourceViewerPageTitle": "Источник",
"filePickerShowHiddenFiles": "Показывать скрытые файлы",
"filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы",
"filePickerOpenFrom": "Открыть",
"filePickerNoItems": "Ничего нет.",
"filePickerUseThisFolder": "Использовать эту папку"
}

View file

@ -99,4 +99,7 @@ class SettingsDefaults {
// accessibility
static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
// file picker
static const filePickerShowHiddenFiles = false;
}

View file

@ -117,6 +117,9 @@ class Settings extends ChangeNotifier {
// version
static const lastVersionCheckDateKey = 'last_version_check_date';
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
// platform settings
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
@ -451,6 +454,12 @@ class Settings extends ChangeNotifier {
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
// file picker
bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles);
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
// convenience methods
// ignore: avoid_positional_boolean_parameters
@ -597,6 +606,7 @@ class Settings extends ChangeNotifier {
case enableVideoAutoPlayKey:
case subtitleShowOutlineKey:
case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey:
if (value is bool) {
_prefs!.setBool(key, value);
} else {

View file

@ -37,8 +37,6 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
}
class PlatformStorageService implements StorageService {
@ -255,25 +253,4 @@ 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, stack) {
await reportService.recordError(e, stack);
}
return null;
}
}

View file

@ -14,11 +14,13 @@ class AIcons {
static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record;
static const IconData error = Icons.error_outline;
static const IconData folder = Icons.folder_outlined;
static const IconData grid = Icons.grid_on_outlined;
static const IconData home = Icons.home_outlined;
static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined;
static const IconData locationOff = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined;

View file

@ -0,0 +1,107 @@
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;
final void Function(String path) onTap;
const CrumbLine({
Key? key,
required this.directory,
required this.onTap,
}) : super(key: key);
@override
_CrumbLineState createState() => _CrumbLineState();
}
class _CrumbLineState extends State<CrumbLine> {
final ScrollController _controller = ScrollController();
VolumeRelativeDirectory get directory => widget.directory;
@override
void didUpdateWidget(covariant CrumbLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.directory.relativeDir.length > oldWidget.directory.relativeDir.length) {
// scroll to show last crumb
WidgetsBinding.instance!.addPostFrameCallback((_) {
final extent = _controller.position.maxScrollExtent;
_controller.animateTo(
extent,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutQuad,
);
});
}
}
@override
Widget build(BuildContext context) {
List<String> parts = [
directory.getVolumeDescription(context),
...p.split(directory.relativeDir),
];
final crumbStyle = Theme.of(context).textTheme.bodyText2;
final crumbColor = crumbStyle!.color!.withOpacity(.4);
return DefaultTextStyle(
style: crumbStyle.copyWith(
color: crumbColor,
fontWeight: FontWeight.w500,
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
controller: _controller,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
Widget _buildText(String text) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(text),
);
if (index >= parts.length) return const SizedBox();
final text = parts[index];
if (index == parts.length - 1) {
return Center(
child: DefaultTextStyle.merge(
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
),
child: _buildText(text),
),
);
}
return GestureDetector(
onTap: () {
final path = p.joinAll([
directory.volumePath,
...parts.skip(1).take(index),
]);
widget.onTap(path);
},
child: Container(
// use a `Container` with a dummy color to make it expand
// so that we can also detect taps around the title `Text`
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildText(text),
Icon(
AIcons.next,
color: crumbColor,
),
],
),
),
);
},
itemCount: parts.length,
),
);
}
}

View file

@ -0,0 +1,203 @@
import 'dart:io';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/buttons.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart';
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';
const FilePicker({Key? key}) : super(key: key);
@override
_FilePickerState createState() => _FilePickerState();
}
class _FilePickerState extends State<FilePicker> {
late VolumeRelativeDirectory _directory;
List<Directory>? _contents;
Set<StorageVolume> get volumes => androidFileUtils.storageVolumes;
String get currentDirectoryPath => p.join(_directory.volumePath, _directory.relativeDir);
@override
void initState() {
super.initState();
final primaryVolume = volumes.firstWhere((v) => v.isPrimary);
_goTo(primaryVolume.path);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final showHidden = settings.filePickerShowHiddenFiles;
final visibleContents = _contents?.where((v) {
if (showHidden) {
return true;
} else {
final isHidden = p.split(v.path).last.startsWith('.');
return !isHidden;
}
}).toList();
return WillPopScope(
onWillPop: () {
if (_directory.relativeDir.isEmpty) {
return SynchronousFuture(true);
}
final parent = p.dirname(currentDirectoryPath);
_goTo(parent);
setState(() {});
return SynchronousFuture(false);
},
child: Scaffold(
appBar: AppBar(
title: Text(_getTitle(context)),
actions: [
MenuIconTheme(
child: PopupMenuButton<_PickerAction>(
itemBuilder: (context) {
return [
PopupMenuItem(
value: _PickerAction.toggleHiddenView,
child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles),
),
];
},
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) {
case _PickerAction.toggleHiddenView:
settings.filePickerShowHiddenFiles = !showHidden;
setState(() {});
break;
}
},
),
),
],
),
drawer: _buildDrawer(context),
body: SafeArea(
child: Column(
children: [
SizedBox(
height: kMinInteractiveDimension,
child: CrumbLine(
directory: _directory,
onTap: (path) {
_goTo(path);
setState(() {});
},
),
),
const Divider(height: 0),
Expanded(
child: visibleContents == null
? const SizedBox()
: visibleContents.isEmpty
? Center(
child: EmptyContent(
icon: AIcons.folder,
text: l10n.filePickerNoItems,
),
)
: ListView.builder(
itemCount: visibleContents.length,
itemBuilder: (context, index) {
return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox();
},
),
),
const Divider(height: 0),
Padding(
padding: const EdgeInsets.all(8),
child: AvesOutlinedButton(
label: l10n.filePickerUseThisFolder,
onPressed: () => Navigator.pop(context, currentDirectoryPath),
),
),
],
),
),
),
);
}
String _getTitle(BuildContext context) {
if (_directory.relativeDir.isEmpty) {
return _directory.getVolumeDescription(context);
}
return p.split(_directory.relativeDir).last;
}
Widget _buildDrawer(BuildContext context) {
return Drawer(
child: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.filePickerOpenFrom,
style: Theme.of(context).textTheme.headline5,
),
),
...volumes.map((v) {
final icon = v.isRemovable ? AIcons.removableStorage : AIcons.mainStorage;
return ListTile(
leading: Icon(icon),
title: Text(v.getDescription(context)),
onTap: () async {
Navigator.pop(context);
await Future.delayed(Durations.drawerTransitionAnimation);
_goTo(v.path);
setState(() {});
},
selected: _directory.volumePath == v.path,
);
})
],
),
);
}
Widget _buildContentLine(BuildContext context, FileSystemEntity content) {
return ListTile(
leading: const Icon(AIcons.folder),
title: Text(p.split(content.path).last),
onTap: () {
_goTo(content.path);
setState(() {});
},
);
}
void _goTo(String path) {
_directory = VolumeRelativeDirectory.fromPath(path)!;
_contents = null;
final contents = <Directory>[];
Directory(currentDirectoryPath).list().listen((event) {
final entity = event.absolute;
if (entity is Directory) {
contents.add(entity);
}
}, onDone: () {
_contents = contents..sort((a, b) => compareAsciiUpperCase(p.split(a.path).last, p.split(b.path).last));
setState(() {});
});
}
}
enum _PickerAction { toggleHiddenView }

View file

@ -2,14 +2,16 @@ 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/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/buttons.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/privacy/file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -164,7 +166,15 @@ class _HiddenPaths extends StatelessWidget {
icon: const Icon(AIcons.add),
label: context.l10n.addPathTooltip,
onPressed: () async {
final path = await storageService.selectDirectory();
final path = await Navigator.push(
context,
MaterialPageRoute<String>(
settings: const RouteSettings(name: FilePicker.routeName),
builder: (context) => const FilePicker(),
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.pageTransitionAnimation * timeDilation);
if (path != null && path.isNotEmpty) {
context.read<CollectionSource>().changeFilterVisibility({PathFilter(path)}, false);
}