nc-photos/lib/widget/dir_picker.dart

401 lines
12 KiB
Dart
Raw Normal View History

2021-06-29 07:42:45 +02:00
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
2021-07-25 07:00:38 +02:00
import 'package:nc_photos/app_localizations.dart';
2021-06-29 07:42:45 +02:00
import 'package:nc_photos/bloc/ls_dir.dart';
import 'package:nc_photos/entity/file.dart';
2021-11-11 15:18:33 +01:00
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
2021-06-29 07:42:45 +02:00
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:path/path.dart' as path;
class DirPicker extends StatefulWidget {
const DirPicker({
Key? key,
required this.account,
required this.rootDir,
this.initialPicks,
2021-10-20 23:00:47 +02:00
this.isMultipleSelections = true,
this.validator,
this.onConfirmed,
}) : super(key: key);
@override
createState() => DirPickerState();
final Account account;
final String rootDir;
2021-10-20 23:00:47 +02:00
final bool isMultipleSelections;
final List<File>? initialPicks;
/// Return whether [dir] is a valid target to be picked
final bool Function(File dir)? validator;
final ValueChanged<List<File>>? onConfirmed;
}
class DirPickerState extends State<DirPicker> {
2021-06-29 07:42:45 +02:00
@override
initState() {
super.initState();
_root = LsDirBlocItem(File(path: widget.rootDir), []);
2021-06-29 07:42:45 +02:00
_initBloc();
if (widget.initialPicks != null) {
_picks.addAll(widget.initialPicks!);
}
2021-06-29 07:42:45 +02:00
}
@override
build(BuildContext context) {
2021-06-29 07:42:45 +02:00
return BlocListener<LsDirBloc, LsDirBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<LsDirBloc, LsDirBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
);
}
/// Calls the onConfirmed method with the current picked dirs
void confirm() {
widget.onConfirmed?.call(_picks);
}
2021-06-29 07:42:45 +02:00
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_navigateInto(File(path: widget.rootDir));
2021-06-29 07:42:45 +02:00
}
Widget _buildContent(BuildContext context, LsDirBlocState state) {
return Stack(
children: [
Align(
alignment: Alignment.center,
child: state is LsDirBlocLoading
? Container()
: _buildList(context, state),
),
if (state is LsDirBlocLoading)
2021-09-15 08:58:06 +02:00
const Align(
2021-06-29 07:42:45 +02:00
alignment: Alignment.topCenter,
2021-09-15 08:58:06 +02:00
child: LinearProgressIndicator(),
2021-06-29 07:42:45 +02:00
),
],
);
}
Widget _buildList(BuildContext context, LsDirBlocState state) {
final isTopLevel = _currentPath == widget.rootDir;
return AnimatedSwitcher(
duration: k.animationDurationNormal,
// see AnimatedSwitcher.defaultLayoutBuilder
layoutBuilder: (currentChild, previousChildren) => Stack(
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
alignment: Alignment.topLeft,
2021-06-29 07:42:45 +02:00
),
child: ListView.separated(
key: Key(_currentPath),
itemBuilder: (context, index) {
if (!isTopLevel && index == 0) {
return ListTile(
dense: true,
leading: const SizedBox(width: 24),
title: Text(L10n.global().rootPickerNavigateUpItemText),
onTap: () {
try {
_navigateInto(File(path: path.dirname(_currentPath)));
} catch (e) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
},
);
} else {
return _buildItem(
context, state.items[index - (isTopLevel ? 0 : 1)]);
}
},
separatorBuilder: (context, index) => const Divider(),
itemCount: state.items.length + (isTopLevel ? 0 : 1),
2021-06-29 07:42:45 +02:00
),
);
}
Widget _buildItem(BuildContext context, LsDirBlocItem item) {
final canPick = widget.validator?.call(item.file) != false;
2021-06-29 07:42:45 +02:00
final pickState = _isItemPicked(item);
2021-07-23 22:05:57 +02:00
IconData? iconData;
if (canPick) {
switch (pickState) {
case _PickState.picked:
2021-10-20 23:00:47 +02:00
iconData = widget.isMultipleSelections
? Icons.check_box
: Icons.radio_button_checked;
break;
case _PickState.childPicked:
2021-10-20 23:00:47 +02:00
iconData = widget.isMultipleSelections
? Icons.indeterminate_check_box
: Icons.remove_circle_outline;
break;
case _PickState.notPicked:
default:
2021-10-20 23:00:47 +02:00
iconData = widget.isMultipleSelections
? Icons.check_box_outline_blank
: Icons.radio_button_unchecked;
break;
}
2021-06-29 07:42:45 +02:00
}
return ListTile(
dense: true,
leading: canPick
? IconButton(
icon: AnimatedSwitcher(
duration: k.animationDurationShort,
transitionBuilder: (child, animation) =>
ScaleTransition(child: child, scale: animation),
child: Icon(
iconData,
key: ValueKey(pickState),
),
),
onPressed: () {
if (pickState == _PickState.picked) {
_unpick(item);
} else {
_pick(item);
}
},
)
2021-09-15 08:58:06 +02:00
: const IconButton(
icon: Icon(null),
onPressed: null,
),
title: Text(item.file.filename),
2021-07-23 22:05:57 +02:00
trailing: item.children?.isNotEmpty == true
? const Icon(Icons.arrow_forward_ios)
: null,
onTap: item.children?.isNotEmpty == true
2021-06-29 07:42:45 +02:00
? () {
try {
_navigateInto(item.file);
} catch (e) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
2021-06-29 07:42:45 +02:00
duration: k.snackBarDurationNormal,
));
}
}
: null,
);
}
void _onStateChange(BuildContext context, LsDirBlocState state) {
if (state is LsDirBlocSuccess) {
if (!_fillResult(_root, state)) {
_log.shout("[_onStateChange] Failed while _fillResult" +
(kDebugMode
? ", root:\n${_root.toString(isDeep: true)}\nstate: ${state.root.path}"
: ""));
}
} else if (state is LsDirBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
2021-06-29 07:42:45 +02:00
duration: k.snackBarDurationNormal,
));
}
}
/// Fill query results from bloc to our item tree
bool _fillResult(LsDirBlocItem root, LsDirBlocSuccess state) {
if (root.file.path == state.root.path) {
if (root.children?.isNotEmpty != true) {
root.children = state.items;
}
2021-06-29 07:42:45 +02:00
return true;
} else if (state.root.path.startsWith(root.file.path)) {
for (final child in root.children ?? <LsDirBlocItem>[]) {
if (_fillResult(child, state)) {
return true;
}
}
return false;
} else {
// not us, not child of us
return false;
}
}
/// Pick an item
void _pick(LsDirBlocItem item) {
setState(() {
2021-10-20 23:00:47 +02:00
if (!widget.isMultipleSelections) {
_picks.clear();
}
2021-06-29 07:42:45 +02:00
_picks.add(item.file);
_picks = _optimizePicks(_root);
});
_log.fine("[_pick] Picked: ${_pickListToString(_picks)}");
}
/// Optimize the picked array
///
/// 1) If a parent directory is picked, all children will be ignored
List<File> _optimizePicks(LsDirBlocItem item) {
if (_picks.any((element) => element.path == item.file.path)) {
// this dir is explicitly picked, nothing more to do
return [item.file];
}
2021-07-23 22:05:57 +02:00
if (item.children == null || item.children!.isEmpty) {
2021-06-29 07:42:45 +02:00
return [];
}
final products = <File>[];
2021-07-23 22:05:57 +02:00
for (final i in item.children!) {
2021-06-29 07:42:45 +02:00
products.addAll(_optimizePicks(i));
}
// // see if all children are being picked
// if (item != _root &&
// products.length >= item.children.length &&
// item.children.every((element) => products.contains(element))) {
// // all children are being picked, add [item] to list and remove its
// // children
// _log.fine(
// "[_optimizePicks] All children under '${item.file.path}' are being picked, optimized");
// return products
// .where((element) => !item.children.contains(element))
// .toList()
// ..add(item);
// }
return products;
}
/// Unpick an item
void _unpick(LsDirBlocItem item) {
setState(() {
if (_picks.any((element) => element.path == item.file.path)) {
// ourself is being picked, simple
_picks =
_picks.where((element) => element.path != item.file.path).toList();
} else {
// Look for the closest picked dir
final parents = _picks
.where((element) => item.file.path.startsWith(element.path))
.toList()
2021-09-15 08:58:06 +02:00
..sort((a, b) => b.path.length.compareTo(a.path.length));
2021-06-29 07:42:45 +02:00
final parent = parents.first;
try {
_picks.addAll(_pickedAllExclude(path: parent.path, exclude: item)
.map((e) => e.file));
_picks.removeWhere((element) => identical(element, parent));
} catch (_) {
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content:
Text(L10n.global().rootPickerUnpickFailureNotification)));
2021-06-29 07:42:45 +02:00
}
}
});
_log.fine("[_unpick] Picked: ${_pickListToString(_picks)}");
}
/// Return a list where all children of [path] or [item], except [exclude],
/// are picked
///
/// Either [path] or [item] must be set, If both are set, [item] takes
/// priority
List<LsDirBlocItem> _pickedAllExclude({
2021-07-23 22:05:57 +02:00
String? path,
LsDirBlocItem? item,
required LsDirBlocItem exclude,
2021-06-29 07:42:45 +02:00
}) {
2021-07-23 22:05:57 +02:00
assert(path != null || item != null);
2021-06-29 07:42:45 +02:00
if (item == null) {
2021-07-23 22:05:57 +02:00
final item = _findChildItemByPath(_root, path!);
2021-06-29 07:42:45 +02:00
return _pickedAllExclude(item: item, exclude: exclude);
}
if (item.file.path == exclude.file.path) {
return [];
}
_log.fine(
"[_pickedAllExclude] Unpicking '${item.file.path}' and picking children");
final products = <LsDirBlocItem>[];
2021-07-23 22:05:57 +02:00
for (final i in item.children ?? []) {
if (file_util.isOrUnderDir(exclude.file, i.file)) {
2021-06-29 07:42:45 +02:00
// [i] is a parent of exclude
products.addAll(_pickedAllExclude(item: i, exclude: exclude));
} else {
products.add(i);
}
}
return products;
}
/// Return the child/grandchild/... item of [parent] with [path]
LsDirBlocItem _findChildItemByPath(LsDirBlocItem parent, String path) {
if (path == parent.file.path) {
return parent;
}
2021-07-23 22:05:57 +02:00
for (final c in parent.children ?? []) {
2021-06-29 07:42:45 +02:00
if (path == c.file.path || path.startsWith("${c.file.path}/")) {
return _findChildItemByPath(c, path);
}
}
// ???
_log.shout(
"[_findChildItemByPath] Failed finding child item for '$path' under '${parent.file.path}'");
throw ArgumentError("Path not found");
}
_PickState _isItemPicked(LsDirBlocItem item) {
var product = _PickState.notPicked;
for (final p in _picks) {
// exact match, or parent is picked
if (file_util.isOrUnderDir(item.file, p)) {
2021-06-29 07:42:45 +02:00
product = _PickState.picked;
// no need to check the remaining ones
break;
}
if (file_util.isUnderDir(p, item.file)) {
2021-06-29 07:42:45 +02:00
product = _PickState.childPicked;
}
}
if (product == _PickState.childPicked) {}
return product;
}
/// Return the string representation of a list of LsDirBlocItem
static String _pickListToString(List<File> items) =>
"['${items.map((e) => e.path).join('\', \'')}']";
void _navigateInto(File file) {
_currentPath = file.path;
_bloc.add(LsDirBlocQuery(widget.account, file, depth: 2));
2021-06-29 07:42:45 +02:00
}
2021-11-11 15:18:33 +01:00
final _bloc = LsDirBloc(const FileRepo(FileWebdavDataSource()));
2021-07-23 22:05:57 +02:00
late LsDirBlocItem _root;
2021-06-29 07:42:45 +02:00
var _currentPath = "";
2021-06-29 07:42:45 +02:00
var _picks = <File>[];
static final _log = Logger("widget.dir_picker.DirPickerState");
2021-06-29 07:42:45 +02:00
}
enum _PickState {
notPicked,
picked,
childPicked,
}