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? initialPicks; /// Return whether [dir] is a valid target to be picked final bool Function(File dir)? validator; final ValueChanged>? onConfirmed; } @npLog class DirPickerState extends State { @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( bloc: _bloc, listener: (context, state) => _onStateChange(context, state), child: BlocBuilder( 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: [ ...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 ?? []) { 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 _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 = []; 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 _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 = []; for (final i in item.children ?? []) { 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 ?? []) { 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 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().fileRepoRemote, isListMinimal: true, ); late LsDirBlocItem _root; var _currentPath = ""; var _picks = []; } enum _PickState { notPicked, picked, childPicked, }