mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +01:00
433 lines
14 KiB
Dart
433 lines
14 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:kiwi/kiwi.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:nc_photos/account.dart';
|
|
import 'package:nc_photos/app_localizations.dart';
|
|
import 'package:nc_photos/bloc/ls_dir.dart';
|
|
import 'package:nc_photos/di_container.dart';
|
|
import 'package:nc_photos/entity/file.dart';
|
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
|
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:np_codegen/np_codegen.dart';
|
|
import 'package:np_ui/np_ui.dart';
|
|
import 'package:path/path.dart' as path_lib;
|
|
|
|
part 'dir_picker.g.dart';
|
|
|
|
class DirPicker extends StatefulWidget {
|
|
const DirPicker({
|
|
Key? key,
|
|
required this.account,
|
|
required this.strippedRootDir,
|
|
this.initialPicks,
|
|
this.isMultipleSelections = true,
|
|
this.validator,
|
|
this.onConfirmed,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
createState() => DirPickerState();
|
|
|
|
final Account account;
|
|
final String strippedRootDir;
|
|
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;
|
|
}
|
|
|
|
@npLog
|
|
class DirPickerState extends State<DirPicker> {
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
_root = LsDirBlocItem(File(path: _rootDir), false, []);
|
|
_initBloc();
|
|
if (widget.initialPicks != null) {
|
|
_picks.addAll(widget.initialPicks!);
|
|
}
|
|
}
|
|
|
|
@override
|
|
build(BuildContext context) {
|
|
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);
|
|
}
|
|
|
|
void _initBloc() {
|
|
_log.info("[_initBloc] Initialize bloc");
|
|
_navigateInto(File(path: _rootDir));
|
|
}
|
|
|
|
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)
|
|
const Align(
|
|
alignment: Alignment.topCenter,
|
|
child: LinearProgressIndicator(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildList(BuildContext context, LsDirBlocState state) {
|
|
final isTopLevel = _currentPath == _rootDir;
|
|
return AnimatedSwitcher(
|
|
duration: k.animationDurationNormal,
|
|
// see AnimatedSwitcher.defaultLayoutBuilder
|
|
layoutBuilder: (currentChild, previousChildren) => Stack(
|
|
alignment: Alignment.topLeft,
|
|
children: <Widget>[
|
|
...previousChildren,
|
|
if (currentChild != null) currentChild,
|
|
],
|
|
),
|
|
// needed to prevent background color overflowing the parent bound, see:
|
|
// https://github.com/flutter/flutter/issues/86584
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
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_lib.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(height: 2),
|
|
itemCount: state.items.length + (isTopLevel ? 0 : 1),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildItem(BuildContext context, LsDirBlocItem item) {
|
|
final canPick = !item.isE2ee && widget.validator?.call(item.file) != false;
|
|
final pickState = _isItemPicked(item);
|
|
|
|
IconData? iconData;
|
|
Color? iconColor;
|
|
if (canPick) {
|
|
switch (pickState) {
|
|
case _PickState.picked:
|
|
iconData = widget.isMultipleSelections
|
|
? Icons.check_box
|
|
: Icons.radio_button_checked;
|
|
iconColor = CheckboxTheme.of(context)
|
|
.fillColor!
|
|
.resolve({MaterialState.selected});
|
|
break;
|
|
|
|
case _PickState.childPicked:
|
|
iconData = widget.isMultipleSelections
|
|
? Icons.indeterminate_check_box
|
|
: Icons.remove_circle_outline;
|
|
iconColor = CheckboxTheme.of(context)
|
|
.fillColor!
|
|
.resolve({MaterialState.selected});
|
|
break;
|
|
|
|
case _PickState.notPicked:
|
|
default:
|
|
iconData = widget.isMultipleSelections
|
|
? Icons.check_box_outline_blank
|
|
: Icons.radio_button_unchecked;
|
|
iconColor = Theme.of(context).colorScheme.onSurface;
|
|
break;
|
|
}
|
|
} else if (item.isE2ee) {
|
|
iconData = Icons.lock_outlined;
|
|
iconColor = M3.of(context).checkbox.disabled.container;
|
|
}
|
|
|
|
return ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
dense: true,
|
|
leading: canPick
|
|
? IconButton(
|
|
icon: AnimatedSwitcher(
|
|
duration: k.animationDurationShort,
|
|
transitionBuilder: (child, animation) =>
|
|
ScaleTransition(scale: animation, child: child),
|
|
child: Icon(
|
|
iconData,
|
|
key: ValueKey(pickState),
|
|
color: iconColor,
|
|
),
|
|
),
|
|
onPressed: () {
|
|
if (pickState == _PickState.picked) {
|
|
_unpick(item);
|
|
} else {
|
|
_pick(item);
|
|
}
|
|
},
|
|
)
|
|
: IconButton(
|
|
icon: Icon(iconData, color: iconColor),
|
|
onPressed: null,
|
|
),
|
|
title: Text(item.file.filename),
|
|
trailing: item.children?.isNotEmpty == true
|
|
? const Icon(Icons.arrow_forward_ios)
|
|
: null,
|
|
textColor:
|
|
item.isE2ee ? M3.of(context).checkbox.disabled.container : null,
|
|
onTap: item.children?.isNotEmpty == true
|
|
? () {
|
|
try {
|
|
_navigateInto(item.file);
|
|
} catch (e) {
|
|
SnackBarManager().showSnackBar(SnackBar(
|
|
content: Text(exception_util.toUserString(e)),
|
|
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)),
|
|
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;
|
|
}
|
|
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(() {
|
|
if (!widget.isMultipleSelections) {
|
|
_picks.clear();
|
|
}
|
|
_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];
|
|
}
|
|
if (item.children == null || item.children!.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
final products = <File>[];
|
|
for (final i in item.children!) {
|
|
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()
|
|
..sort((a, b) => b.path.length.compareTo(a.path.length));
|
|
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(
|
|
content: Text(L10n.global().rootPickerUnpickFailureNotification),
|
|
duration: k.snackBarDurationNormal,
|
|
));
|
|
}
|
|
}
|
|
});
|
|
_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({
|
|
String? path,
|
|
LsDirBlocItem? item,
|
|
required LsDirBlocItem exclude,
|
|
}) {
|
|
assert(path != null || item != null);
|
|
if (item == null) {
|
|
final item = _findChildItemByPath(_root, path!);
|
|
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>[];
|
|
for (final i in item.children ?? <LsDirBlocItem>[]) {
|
|
if (file_util.isOrUnderDir(exclude.file, i.file)) {
|
|
// [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;
|
|
}
|
|
for (final c in parent.children ?? <LsDirBlocItem>[]) {
|
|
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)) {
|
|
product = _PickState.picked;
|
|
// no need to check the remaining ones
|
|
break;
|
|
}
|
|
if (file_util.isUnderDir(p, item.file)) {
|
|
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));
|
|
}
|
|
|
|
late final String _rootDir =
|
|
file_util.unstripPath(widget.account, widget.strippedRootDir);
|
|
|
|
late final _bloc = LsDirBloc(
|
|
KiwiContainer().resolve<DiContainer>().fileRepoRemote,
|
|
isListMinimal: true,
|
|
);
|
|
late LsDirBlocItem _root;
|
|
|
|
var _currentPath = "";
|
|
var _picks = <File>[];
|
|
}
|
|
|
|
enum _PickState {
|
|
notPicked,
|
|
picked,
|
|
childPicked,
|
|
}
|