Files
namida/lib/controller/file_browser.dart
MSOB7YY c8e12c258f core: migrate translations to arb to fix plurals
- redesign index refresh prompt dialog
- organize some files
2026-03-04 02:37:16 +02:00

1525 lines
56 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:namida/base/pull_to_refresh.dart';
import 'package:namida/controller/directory_index.dart';
import 'package:namida/controller/navigator_controller.dart';
import 'package:namida/controller/platform/namida_storage/namida_storage.dart';
import 'package:namida/controller/settings_controller.dart';
import 'package:namida/core/constants.dart';
import 'package:namida/core/extensions.dart';
import 'package:namida/core/icon_fonts/broken_icons.dart';
import 'package:namida/core/namida_converter_ext.dart';
import 'package:namida/core/themes.dart';
import 'package:namida/core/translations/language.dart';
import 'package:namida/core/utils.dart';
import 'package:namida/main.dart';
import 'package:namida/packages/three_arched_circle.dart';
import 'package:namida/ui/dialogs/edit_tags_dialog.dart';
import 'package:namida/ui/widgets/artwork.dart';
import 'package:namida/ui/widgets/custom_widgets.dart';
import 'package:namida/ui/widgets/expandable_box.dart';
enum FileBrowserSortType {
name,
dateModified,
type, // extension
size,
}
const _defaultMemeType = NamidaStorageFileMemeType.any;
class NamidaFileBrowser {
static Future<File?> pickFile({
String note = '',
NamidaStorageFileMemeType memeType = _defaultMemeType,
String? initialDirectory,
NamidaFileExtensionsWrapper? allowedExtensions,
}) async {
return _NamidaFileBrowserBase.pickFile(
note: note,
allowedExtensions: allowedExtensions,
memeType: memeType,
initialDirectory: initialDirectory,
onNavigate: _onNavigate,
onPop: _onPop,
);
}
static Future<List<File>> pickFiles({
String note = '',
NamidaStorageFileMemeType memeType = _defaultMemeType,
String? initialDirectory,
NamidaFileExtensionsWrapper? allowedExtensions,
}) async {
return _NamidaFileBrowserBase.pickFiles(
note: note,
allowedExtensions: allowedExtensions,
memeType: memeType,
initialDirectory: initialDirectory,
onNavigate: _onNavigate,
onPop: _onPop,
);
}
static Future<Directory?> pickDirectory({
String note = '',
String? initialDirectory,
}) async {
return _NamidaFileBrowserBase.pickDirectory(
note: note,
initialDirectory: initialDirectory,
onNavigate: _onNavigate,
onPop: _onPop,
);
}
static Future<List<Directory>> pickDirectories({
String note = '',
String? initialDirectory,
}) async {
return _NamidaFileBrowserBase.pickDirectories(
note: note,
initialDirectory: initialDirectory,
onNavigate: _onNavigate,
onPop: _onPop,
);
}
static Future<String?> getDirectory({String note = ''}) async {
return pickDirectory(note: note).then((value) => value?.path);
}
static Future<List<String>> getDirectories({String note = ''}) async {
return pickDirectories(note: note).then((value) => value.map((e) => e.path).toList());
}
static void _onNavigate(_NamidaFileBrowserBase widget) {
NamidaNavigator.inst.navigateToRoot(
widget,
transition: Transition.native,
);
}
static void _onPop() {
NamidaNavigator.inst.popRoot();
}
}
typedef _NamidaFileBrowserNavigationCallback = void Function(_NamidaFileBrowserBase options);
typedef _NamidaFileBrowserPopCallback = void Function();
class _NamidaFileBrowserBase<T extends FileSystemEntity> extends StatefulWidget {
final String note;
final String? initialDirectory;
final Completer<List<T>> onSelect;
final NamidaFileExtensionsWrapper? allowedExtensions;
final NamidaStorageFileMemeType memeType;
final bool allowMultiple;
final _NamidaFileBrowserPopCallback onPop;
const _NamidaFileBrowserBase({
super.key,
required this.note,
this.initialDirectory,
required this.onSelect,
this.allowedExtensions,
this.memeType = _defaultMemeType,
required this.allowMultiple,
required this.onPop,
});
static Future<File?> pickFile({
String note = '',
NamidaFileExtensionsWrapper? allowedExtensions,
NamidaStorageFileMemeType memeType = _defaultMemeType,
String? initialDirectory,
required _NamidaFileBrowserNavigationCallback onNavigate,
required _NamidaFileBrowserPopCallback onPop,
}) async {
final completer = Completer<List<File>>();
onNavigate(
_NamidaFileBrowserBase<File>(
note: note,
initialDirectory: initialDirectory,
allowedExtensions: allowedExtensions,
memeType: memeType,
onSelect: completer,
allowMultiple: false,
onPop: onPop,
),
);
final all = await completer.future;
return all.firstOrNull;
}
static Future<List<File>> pickFiles({
String note = '',
NamidaFileExtensionsWrapper? allowedExtensions,
NamidaStorageFileMemeType memeType = _defaultMemeType,
String? initialDirectory,
required _NamidaFileBrowserNavigationCallback onNavigate,
required _NamidaFileBrowserPopCallback onPop,
}) async {
final completer = Completer<List<File>>();
onNavigate(
_NamidaFileBrowserBase<File>(
note: note,
initialDirectory: initialDirectory,
allowedExtensions: allowedExtensions,
memeType: memeType,
onSelect: completer,
allowMultiple: true,
onPop: onPop,
),
);
return completer.future;
}
static Future<Directory?> pickDirectory({
String note = '',
String? initialDirectory,
required _NamidaFileBrowserNavigationCallback onNavigate,
required _NamidaFileBrowserPopCallback onPop,
}) async {
final completer = Completer<List<Directory>>();
onNavigate(
_NamidaFileBrowserBase<Directory>(
note: note,
initialDirectory: initialDirectory,
onSelect: completer,
allowMultiple: false,
onPop: onPop,
),
);
final all = await completer.future;
if (all.isEmpty) return null;
return _fixSDCardDirectory(all[0]);
}
static Future<List<Directory>> pickDirectories({
String note = '',
String? initialDirectory,
required _NamidaFileBrowserNavigationCallback onNavigate,
required _NamidaFileBrowserPopCallback onPop,
}) async {
final completer = Completer<List<Directory>>();
onNavigate(
_NamidaFileBrowserBase<Directory>(
note: note,
initialDirectory: initialDirectory,
onSelect: completer,
allowMultiple: true,
onPop: onPop,
),
);
final res = await completer.future;
return res.map((e) => _fixSDCardDirectory(e)).toList();
}
static final _sdDirRegex = RegExp(r'/tree/(\w{4}-\w{4}):');
static Directory _fixSDCardDirectory(Directory dir) {
final replaced = dir.path.replaceFirstMapped(_sdDirRegex, (match) => '/storage/${match.group(1)}/');
return Directory(replaced);
}
@override
State<_NamidaFileBrowserBase> createState() => _NamidaFileBrowserState<T>();
}
class _NamidaFileBrowserState<T extends FileSystemEntity> extends State<_NamidaFileBrowserBase<T>> with TickerProviderStateMixin, PullToRefreshMixin {
final _mainStoragePaths = <String>{};
String _currentFolderPath = '';
var _currentFiles = <File>[];
var _currentFolders = <Directory>[];
bool _isFetching = true;
final _sortTypeToName = {
FileBrowserSortType.name: lang.name,
FileBrowserSortType.dateModified: lang.date,
FileBrowserSortType.type: lang.extension,
FileBrowserSortType.size: lang.size,
};
void _sortItems(FileBrowserSortType? type, bool? reversed, {bool refreshState = true}) {
type ??= settings.fileBrowserSort.value;
reversed ??= settings.fileBrowserSortReversed.value;
void sorterFnFiles(Comparable<dynamic> Function(File item) fn) {
reversed! ? _currentFiles.sortByReverse(fn) : _currentFiles.sortBy(fn);
}
void sorterFnFolder(Comparable<dynamic> Function(Directory item) fn) {
reversed! ? _currentFolders.sortByReverse(fn) : _currentFolders.sortBy(fn);
}
switch (type) {
case FileBrowserSortType.name:
sorterFnFiles((item) => _pathToName(item.path).toLowerCase());
sorterFnFolder((item) => _pathToName(item.path).toLowerCase());
case FileBrowserSortType.dateModified:
sorterFnFiles((item) => _currentInfoFiles[item.path]?.modified ?? DateTime(0));
sorterFnFolder((item) => _currentInfoDirs[item.path]?.modified ?? DateTime(0));
case FileBrowserSortType.type:
sorterFnFiles((item) => _pathToExtension(item.path).toLowerCase());
sorterFnFolder((item) => _pathToExtension(item.path).toLowerCase());
case FileBrowserSortType.size:
sorterFnFiles((item) => _currentInfoFiles[item.path]?.size ?? 0);
sorterFnFolder((item) => _currentInfoDirs[item.path]?.size ?? 0);
}
if (type != settings.fileBrowserSort.value || reversed != settings.fileBrowserSortReversed.value) {
settings.save(
fileBrowserSort: type,
fileBrowserSortReversed: reversed,
);
}
if (refreshState) setState(() {});
}
final _showHiddenFiles = false.obs;
bool _showEmptyFolders = false;
static final _pathSeparator = Platform.pathSeparator;
late final _scrollController = NamidaScrollController.create();
late final _pathSplitsScrollController = NamidaScrollController.create();
static String _pathToName(String path) {
return path.pathReverseSplitter(_pathSeparator);
}
static String _pathToExtension(String path) {
return path.pathReverseSplitter('.').toLowerCase();
}
Isolate? _isolate;
ReceivePort? _resultPort;
Isolate? _infoIsolate;
ReceivePort? _infoPort;
void _stopMainIsolates() {
try {
_resultPort?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
_resultPort = null;
} catch (_) {}
}
void _stopInfoIsolates() {
try {
_infoPort?.close();
_infoIsolate?.kill(priority: Isolate.immediate);
_infoIsolate = null;
_infoPort = null;
} catch (_) {}
}
Future<void> _fetchFiles(Directory dir, {bool clearPrevious = true}) async {
final dirPath = dir.path;
_currentFolderPath = dirPath;
if (clearPrevious) {
setState(() {
_isFetching = true;
_currentFiles = [];
_currentFolders = [];
});
}
(List<File>, List<Directory>, Object?) isolateRes = ([], [], null);
try {
_stopMainIsolates();
_resultPort = ReceivePort();
final params = (dir: dirPath, showHiddenFiles: _showHiddenFiles.value, allowedExtensionsWrappers: _effectiveAllowedExtensions, resultPort: _resultPort!.sendPort);
_isolate = await Isolate.spawn(_fetchFilesIsolate, params);
isolateRes = await _resultPort!.first as (List<File>, List<Directory>, Object?);
// _fetchInfo(dirPath, isolateRes.$1, isolateRes.$2);
_stopMainIsolates();
} catch (e) {
snackyy(title: lang.error, message: "$e", isError: true);
}
if (dirPath == _currentFolderPath) {
setState(() {
_isFetching = false;
if (isolateRes.$1.isNotEmpty) _currentFiles = isolateRes.$1;
if (isolateRes.$2.isNotEmpty) _currentFolders = isolateRes.$2;
_sortItems(null, null, refreshState: false);
});
if (isolateRes.$3 != null) {
snackyy(title: lang.error, message: isolateRes.$3!.toString(), isError: true);
}
}
}
var _currentInfoFiles = <String, NamidaFileStat>{};
var _currentInfoDirs = <String, NamidaDirStat>{};
bool _fetchingInfo = false;
Future<void> _fetchInfo(Set<String> rootPaths) async {
_fetchingInfo = true;
try {
_infoPort = ReceivePort();
final params = (rootPaths, _infoPort!.sendPort);
_infoIsolate = await Isolate.spawn(_fetchInfoIsolate, params);
final res = await _infoPort!.first as (Map<String, NamidaFileStat>, Map<String, NamidaDirStat>);
if (mounted) {
setState(() {
_currentInfoFiles = res.$1;
_currentInfoDirs = res.$2;
if (settings.fileBrowserSort.value != FileBrowserSortType.name) _sortItems(null, null, refreshState: false);
});
}
_stopInfoIsolates();
} catch (_) {}
_fetchingInfo = false;
}
static void _fetchInfoIsolate((Set<String>, SendPort) params) {
final infoFiles = <String, NamidaFileStat>{}; // <------
final directoryForFiles = <String, List<String>>{}; // --^
final directoryForFolders = <String, List<String>>{}; // --^
final infoDirSize = <String, int>{};
int onFileAdd(Directory dir, File f) {
try {
final stats = f.statSync();
infoFiles[f.path] = NamidaFileStat(
size: stats.size,
accessed: stats.accessed,
changed: stats.changed,
modified: stats.modified,
);
directoryForFiles.addForce(dir.path, f.path);
return stats.size;
} catch (_) {
return 0;
}
}
void dirToParentsWalker(Directory dir, void Function(String parent) execute) {
final dirPieces = dir.path.split(_pathSeparator);
while (dirPieces.isNotEmpty) {
final parentDirPath = dirPieces.join(_pathSeparator);
execute(parentDirPath);
dirPieces.removeLast();
}
}
final infoDirs = <String, NamidaDirStat>{};
void markDirAndParentAsInaccurate(Directory dir) {
dirToParentsWalker(dir, (parent) {
final currentInfo = infoDirs[parent];
if (currentInfo?.accurate == false) return; // already marked
infoDirs[parent] = currentInfo != null
? NamidaDirStat(
accurate: false,
filesCount: currentInfo.filesCount,
foldersCount: currentInfo.foldersCount,
size: currentInfo.size,
accessed: currentInfo.accessed,
changed: currentInfo.changed,
modified: currentInfo.modified,
)
: NamidaDirStat(
accurate: false,
filesCount: 0,
foldersCount: 0,
size: 0,
accessed: DateTime(0),
changed: DateTime(0),
modified: DateTime(0),
);
});
}
int dirSafeRecursiveListSync(Directory dir) {
try {
int totalSize = 0;
final subDir = <Directory>[];
final items = dir.listSync(recursive: false);
items.loop((e) {
if (e is File) {
totalSize += onFileAdd(dir, e);
} else if (e is Directory) {
subDir.add(e);
directoryForFolders.addForce(dir.path, e.path);
}
});
subDir.loop((sub) {
totalSize += dirSafeRecursiveListSync(
sub,
);
});
infoDirSize[dir.path] = totalSize;
return totalSize;
} catch (_) {
markDirAndParentAsInaccurate(dir);
return 0;
}
}
for (final rootPath in params.$1) {
dirSafeRecursiveListSync(Directory(rootPath));
}
void onDirInfo(MapEntry<String, List<String>> dirEntry, int size) {
final dir = Directory(dirEntry.key);
try {
final dirStat = dir.statSync();
infoDirs[dir.path] = NamidaDirStat(
accurate: infoDirs[dir.path]?.accurate ?? true, // check if was marked innaccurate before.
filesCount: directoryForFiles[dir.path]?.length ?? 0,
foldersCount: directoryForFolders[dir.path]?.length ?? 0,
size: size,
accessed: dirStat.accessed,
changed: dirStat.changed,
modified: dirStat.modified,
);
} catch (_) {}
}
for (final fileEntry in directoryForFiles.entries) {
int totalSize = 0;
fileEntry.value.loop((e) => totalSize += infoFiles[e]?.size ?? 0);
onDirInfo(fileEntry, totalSize);
}
for (final dirEntry in directoryForFolders.entries) {
onDirInfo(dirEntry, infoDirSize[dirEntry.key] ?? 0);
}
params.$2.send((infoFiles, infoDirs));
}
// Future<void> _fetchInfo(String forPath, List<File> files, List<Directory> dirs) async {
// final infoPort = await preparePortRaw(
// onResult: (result) {
// final res = result as (String, Map, Type);
// if (res.$1 != _currentFolderPath) return;
// setState(() {
// if (res.$2 is Map<String, NamidaFileStat>) {
// _currentInfoFiles = res.$2 as Map<String, NamidaFileStat>;
// } else if (res.$3 is Map<String, NamidaDirStat>) {
// _currentInfoDirs = res.$2 as Map<String, NamidaDirStat>;
// }
// });
// },
// isolateFunction: (itemsSendPort) async {
// await Isolate.spawn(_fetchInfoIsolate, itemsSendPort);
// },
// );
// final params = (forPath, files, dirs);
// infoPort.send(params);
// }
// static void _fetchInfoIsolate(SendPort sendPort) {
// final allFilesStats = <String, NamidaFileStat>{};
// final allDirsStats = <String, NamidaDirStat>{};
// final recievePort = ReceivePort();
// sendPort.send(recievePort.sendPort);
// StreamSubscription? streamSub;
// streamSub = recievePort.listen((p) async {
// if (PortsProvider.isDisposeMessage(p)) {
// recievePort.close();
// streamSub?.cancel();
// return;
// }
// p as (String forPath, List<File> files, List<Directory>);
// // -- files
// if (p.$2.isNotEmpty) {
// final newMapFiles = <String, NamidaFileStat>{};
// p.$2.loop((e){
// try {
// if (allFilesStats[e.path] == null) {
// final stats = e.statSync();
// allFilesStats[e.path] = NamidaFileStat(
// size: stats.size,
// accessed: stats.accessed,
// changed: stats.changed,
// modified: stats.modified,
// );
// }
// newMapFiles[e.path] = allFilesStats[e.path]!;
// } catch (_) {}
// });
// sendPort.send((p.$1, newMapFiles, File));
// }
// // -- dirs
// if (p.$3.isNotEmpty) {
// final newMapDirs = <String, NamidaDirStat>{};
// p.$3.loop((dir){
// try {
// int totalSize = 0;
// if (allDirsStats[dir.path] == null) {
// final dirStats = dir.statSync();
// var itemsInside = <FileSystemEntity>[];
// try {
// itemsInside = dir.listSync(recursive: true);
// } catch (e) {
// itemsInside = dir.listSync(recursive: false);
// }
// int filesCount = 0;
// int foldersCount = 0;
// itemsInside.loop((file){
// if (file is File) {
// // -- file stats inside each dir
// final stats = file.statSync();
// allFilesStats[file.path] ??= NamidaFileStat(
// size: stats.size,
// accessed: stats.accessed,
// changed: stats.changed,
// modified: stats.modified,
// );
// totalSize += stats.size;
// filesCount++;
// } else {
// foldersCount++;
// }
// });
// allDirsStats[dir.path] = NamidaDirStat(
// filesCount: filesCount,
// foldersCount: foldersCount,
// size: totalSize,
// accessed: dirStats.accessed,
// changed: dirStats.changed,
// modified: dirStats.modified,
// );
// }
// newMapDirs[dir.path] = allDirsStats[dir.path]!;
// } catch (_) {}
// });
// sendPort.send((p.$1, newMapDirs, Directory));
// }
// });
// }
static void _fetchFilesIsolate(({String dir, List<NamidaFileExtensionsWrapper> allowedExtensionsWrappers, bool showHiddenFiles, SendPort resultPort}) params) {
List<FileSystemEntity> items;
try {
items = Directory(params.dir).listSync();
} catch (e) {
params.resultPort.send((<File>[], <Directory>[], e));
return;
}
final files = <File>[];
final dirs = <Directory>[];
void onAdd(FileSystemEntity e) {
if (e is File) {
files.add(e);
} else if (e is Directory) {
dirs.add(e);
}
}
final excludeHidden = params.showHiddenFiles == false;
final extensionsWrappers = params.allowedExtensionsWrappers;
if (excludeHidden && extensionsWrappers.isNotEmpty) {
items.loop((e) {
final filename = e.path.splitLast(_pathSeparator);
if (e is Directory) {
if (!filename.startsWith('.')) onAdd(e);
} else {
if (!filename.startsWith('.') && extensionsWrappers.any((wrapper) => wrapper.isPathValid(filename))) onAdd(e);
}
});
} else if (excludeHidden) {
items.loop((e) {
final fileorDirName = e.path.splitLast(_pathSeparator);
if (!fileorDirName.startsWith('.')) onAdd(e);
});
} else if (extensionsWrappers.isNotEmpty) {
items.loop((e) {
if (e is File) {
final filename = e.path.splitLast(_pathSeparator);
if (extensionsWrappers.any((wrapper) => wrapper.isPathValid(filename))) onAdd(e);
} else {
onAdd(e);
}
});
} else {
items.loop((e) => onAdd(e));
}
files.sortBy((e) => _pathToName(e.path));
dirs.sortBy((e) => _pathToName(e.path));
params.resultPort.send((files, dirs, null));
}
final _effectiveAllowedExtensions = <NamidaFileExtensionsWrapper>[];
@override
void initState() {
super.initState();
_refreshPermissionStatus();
NamidaStorage.inst.getStorageDirectories().then((paths) {
_mainStoragePaths.addAll(paths);
_fetchFiles(Directory(widget.initialDirectory ?? paths.first));
_fetchInfo(_mainStoragePaths);
});
final allowedExtensions = widget.allowedExtensions;
if (allowedExtensions != null) _effectiveAllowedExtensions.add(allowedExtensions);
if (widget.memeType != NamidaStorageFileMemeType.any) {
switch (widget.memeType) {
case NamidaStorageFileMemeType.audio:
_effectiveAllowedExtensions.add(NamidaFileExtensionsWrapper.audio);
case NamidaStorageFileMemeType.video:
_effectiveAllowedExtensions.add(NamidaFileExtensionsWrapper.video);
case NamidaStorageFileMemeType.image:
_effectiveAllowedExtensions.add(NamidaFileExtensionsWrapper.image);
case NamidaStorageFileMemeType.media:
_effectiveAllowedExtensions
..add(NamidaFileExtensionsWrapper.audio)
..add(NamidaFileExtensionsWrapper.video);
case NamidaStorageFileMemeType.any:
}
}
_initIconsLookup();
if (isDesktop) {
Timer(
const Duration(milliseconds: 200), // give time for ui to layout to avoid hittest errors
() => _onBackupPickerLaunch(_effectiveAllowedExtensions),
);
}
}
@override
void dispose() {
_stopMainIsolates();
_stopInfoIsolates();
_scrollController.dispose();
_pathSplitsScrollController.dispose();
_showHiddenFiles.close();
_hasPermissionRx.close();
super.dispose();
}
bool isPathRoot(String path) {
return _mainStoragePaths.any(
(element) {
if (element == path) return true;
if (!element.endsWith(_pathSeparator)) element += _pathSeparator;
if (!path.endsWith(_pathSeparator)) path += _pathSeparator;
return element == path;
},
);
}
final _scrollPositionsSaved = <String, double>{}; // path: offset
void _navigateTo(Directory dir, {double? scrollOffset}) {
try {
_scrollPositionsSaved[_currentFolderPath] = _scrollController.offset; // saving current offset.
} catch (_) {}
_fetchFiles(dir);
if (_scrollController.hasClients) _scrollController.jumpTo(scrollOffset ?? 0);
try {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (_pathSplitsScrollController.hasClients) {
_pathSplitsScrollController.animateTo(
_pathSplitsScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutQuart,
);
}
});
} catch (_) {}
}
void _navigateBack() {
final pieces = _currentFolderPath.split(_pathSeparator);
pieces.removeLast();
final newPath = pieces.join(_pathSeparator);
_navigateTo(Directory(newPath), scrollOffset: _scrollPositionsSaved[newPath]);
}
void _onSelectionComplete(List<T> items) {
widget.onSelect.completeIfWasnt(items);
widget.onPop();
}
final _selectedFiles = <File>[];
final _selectedFilesLookup = <String, bool>{};
void _onFileTap(File file) {
if (_selectedFiles.isNotEmpty) {
_onFileLongPress(file);
} else {
if (T == File) {
_onSelectionComplete([file as T]);
}
}
}
void _onFileLongPress(File file) {
if (T != File) return;
final alreadySelected = _selectedFilesLookup[file.path] == true;
if (_selectedFiles.isNotEmpty && !widget.allowMultiple) {
_selectedFiles.clear();
_selectedFilesLookup.clear();
}
if (alreadySelected) {
setState(() {
_selectedFiles.remove(file);
_selectedFilesLookup[file.path] = false;
});
} else {
setState(() {
_selectedFiles.add(file);
_selectedFilesLookup[file.path] = true;
});
}
}
final _selectedFolders = <Directory>[];
final _selectedFoldersLookup = <String, bool>{};
void _onFolderTap(Directory dir) {
if (_selectedFolders.isNotEmpty) {
_onFolderLongPress(dir);
} else {
_navigateTo(dir);
}
}
void _onFolderLongPress(Directory dir) {
if (T != Directory) return;
final alreadySelected = _selectedFoldersLookup[dir.path] == true;
if (_selectedFolders.isNotEmpty && !widget.allowMultiple) {
_selectedFolders.clear();
_selectedFoldersLookup.clear();
}
if (alreadySelected) {
setState(() {
_selectedFolders.remove(dir);
_selectedFoldersLookup[dir.path] = false;
});
} else {
setState(() {
_selectedFolders.add(dir);
_selectedFoldersLookup[dir.path] = true;
});
}
}
IconData _fileToIcon(File file) {
final extension = _pathToExtension(file.path);
final iconIndex = _iconsLookupPre[extension];
if (iconIndex != null) {
final icon = _iconsLookup[iconIndex];
if (icon != null) return icon;
}
return Broken.document_1;
}
ArtworkWidget? _getFileImage(File file) {
final extension = _pathToExtension(file.path);
final iconIndex = _iconsLookupPre[extension];
if (iconIndex != 2) return null;
return ArtworkWidget(
key: Key(file.path),
thumbnailSize: 56.0,
path: file.path,
borderRadius: 8.0,
blur: 4.0,
disableBlurBgSizeShrink: true,
fallbackToFolderCover: false,
icon: _iconsLookup[2] ?? Broken.gallery,
);
}
void _initIconsLookup() {
for (final e in NamidaFileExtensionsWrapper.audio.extensions) {
_iconsLookupPre[e] = 0;
}
for (final e in NamidaFileExtensionsWrapper.video.extensions) {
_iconsLookupPre[e] = 1;
}
for (final e in NamidaFileExtensionsWrapper.image.extensions) {
_iconsLookupPre[e] = 2;
}
for (final e in [...NamidaFileExtensionsWrapper.json.extensions, ...NamidaFileExtensionsWrapper.csv.extensions]) {
_iconsLookupPre[e] = 3;
}
for (final e in NamidaFileExtensionsWrapper.m3u.extensions) {
_iconsLookupPre[e] = 4;
}
for (final e in NamidaFileExtensionsWrapper.compressed.extensions) {
_iconsLookupPre[e] = 5;
}
_iconsLookup[0] = Broken.musicnote;
_iconsLookup[1] = Broken.video;
_iconsLookup[2] = Broken.gallery;
_iconsLookup[3] = Broken.document_code;
_iconsLookup[4] = Broken.music_filter;
_iconsLookup[5] = Broken.external_drive;
}
final _iconsLookupPre = <String, int>{};
final _iconsLookup = <int, IconData>{};
Future<void> _onBackupPickerLaunch([List<NamidaFileExtensionsWrapper>? allowedExtensions]) async {
final note = widget.note != '' ? widget.note : null;
if (T == File) {
final res = await NamidaStorage.inst.pickFiles(
note: note,
multiple: widget.allowMultiple,
memetype: widget.memeType,
allowedExtensions: allowedExtensions,
);
final files = res.map((e) => File(e)).toList();
if (files.isNotEmpty) _onSelectionComplete(files as List<T>);
} else if (T == Directory) {
final res = await NamidaStorage.inst.pickDirectory(note: note);
if (res != null) _onSelectionComplete([Directory(res) as T]);
}
}
List<Widget> get _getCurrentPathsSplitsChildren {
if (_currentFolderPath == '') return [];
final currentRoot = _mainStoragePaths.firstWhere((element) => _currentFolderPath.startsWith(element));
final pathWithoutRoot = _currentFolderPath.substring(currentRoot.length);
final splits = pathWithoutRoot.split(_pathSeparator);
final map = <int, String>{};
if (splits.isNotEmpty) {
final sdCardRegex = RegExp(r'\w{4}-\w{4}', caseSensitive: false);
map[0] = sdCardRegex.hasMatch(currentRoot) ? 'SD Card' : 'Home';
int index = 1;
final splitsSkipped = Platform.isWindows ? splits.skip(0) : splits.skip(1);
for (final part in splitsSkipped) {
if (part.isNotEmpty) {
map[index] = part;
}
index++;
}
}
final textTheme = context.textTheme;
final widgets = <Widget>[];
for (final e in map.entries) {
widgets.add(
TapDetector(
onTap: () {
if (e.key == map.length - 1) return; // same path
String newDirPath = currentRoot;
for (final entry in map.entries.skip(1)) {
if (entry.key > e.key) break;
newDirPath += _pathSeparator + entry.value;
}
if (newDirPath.startsWith(currentRoot)) {
_navigateTo(Directory(newDirPath));
}
},
child: DecoratedBox(
decoration: BoxDecoration(
color: context.theme.cardColor,
borderRadius: BorderRadius.circular(8.0.multipliedRadius),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
e.value == '' ? _pathSeparator : e.value,
style: textTheme.displayMedium,
),
),
),
),
);
widgets.add(
const Icon(
Broken.arrow_right_3,
size: 16.0,
),
);
}
widgets.removeLast();
return widgets;
}
final _hasPermissionRx = true.obs; // assume yes until confirmed
void _refreshPermissionStatus() async {
_hasPermissionRx.value = await requestManageStoragePermission();
}
@override
Widget build(BuildContext context) {
final theme = context.theme;
final textTheme = theme.textTheme;
final chipColor = theme.cardColor;
final pathSplitsChildren = _getCurrentPathsSplitsChildren;
return WillPopScope(
onWillPop: () async {
if (isPathRoot(_currentFolderPath)) {
_onSelectionComplete(<T>[]);
} else {
_navigateBack();
}
return false;
},
child: BackgroundWrapper(
child: SafeArea(
child: Column(
children: [
ObxO(
rx: _hasPermissionRx,
builder: (context, hasPermission) => hasPermission
? const SizedBox()
: NamidaInkWell(
onTap: _refreshPermissionStatus,
borderRadius: 8.0,
bgColor: Colors.red.withOpacityExt(0.1),
margin: EdgeInsets.symmetric(horizontal: 8.0),
padding: EdgeInsets.symmetric(vertical: 10.0),
child: Row(
children: [
SizedBox(width: 8.0),
Icon(
Broken.warning_2,
size: 24.0,
),
SizedBox(width: 8.0),
Expanded(
child: Text(
lang.grantStoragePermission,
style: textTheme.displayMedium?.copyWith(
fontSize: 16.0,
),
),
),
SizedBox(width: 8.0),
NamidaInkWellButton(
onTap: _refreshPermissionStatus,
borderRadius: 6.0,
icon: null,
text: lang.manage,
),
SizedBox(width: 8.0),
],
),
),
),
Row(
children: [
const SizedBox(width: 4.0),
IconButton(
onPressed: () {
_onSelectionComplete(<T>[]);
},
icon: const Icon(
Broken.arrow_left_2,
size: 24.0,
),
),
const SizedBox(width: 4.0),
Expanded(
child: widget.note != ''
? Text(
widget.note.addDQuotation(),
style: textTheme.displayMedium?.copyWith(
fontSize: 16.0,
),
)
: const SizedBox(),
),
if (T == Directory)
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
final dirController = TextEditingController();
NamidaNavigator.inst.navigateDialog(
onDisposing: () {
dirController.dispose();
},
dialogBuilder: (theme) => CustomBlurryDialog(
title: lang.newDirectory,
actions: [
const CancelButton(),
NamidaButton(
text: lang.choose,
onPressed: () {
final text = dirController.text;
if (text.length > 2) {
NamidaNavigator.inst.closeDialog();
_onSelectionComplete([Directory(text) as T]);
}
},
),
],
child: Column(
children: [
const SizedBox(height: 12.0),
CustomTagTextField(
controller: dirController,
hintText: '',
labelText: lang.newDirectory,
validatorMode: AutovalidateMode.always,
validator: (value) {
value ??= '';
if (value.isEmpty) {
return lang.pleaseEnterAName;
}
try {
if (!DirectoryIndexLocal(value).existsSync()) {
return lang.directoryDoesntExist;
}
} catch (e) {
return e.toString();
}
return null;
},
),
const SizedBox(height: 12.0),
],
),
),
);
},
icon: Icon(
Broken.add_circle,
size: 20.0,
color: _showHiddenFiles.value ? null : context.defaultIconColor(),
),
),
IconButton(
tooltip: 'Show empty folders',
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
setState(() => _showEmptyFolders = !_showEmptyFolders);
},
icon: StackedIcon(
baseIcon: Broken.folder,
secondaryIcon: _showEmptyFolders ? Broken.eye : Broken.eye_slash,
iconSize: 20.0,
secondaryIconSize: 12.0,
disableColor: _showEmptyFolders,
),
),
IconButton(
tooltip: 'Show hidden files/folders',
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
_showHiddenFiles.value = !_showHiddenFiles.value;
_fetchFiles(Directory(_currentFolderPath));
},
icon: Obx(
(context) => Icon(
_showHiddenFiles.valueR ? Broken.eye : Broken.eye_slash,
size: 20.0,
color: _showHiddenFiles.valueR ? null : context.defaultIconColor(),
),
),
),
LongPressDetector(
onLongPress: () => _onBackupPickerLaunch(), // launching without extensions filter
child: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () => _onBackupPickerLaunch(_effectiveAllowedExtensions),
icon: Icon(
Broken.export_1,
size: 20.0,
color: context.defaultIconColor(),
),
),
),
const SizedBox(width: 12.0),
],
),
if (_mainStoragePaths.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
child: SizedBox(
width: context.width,
child: Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.start,
runSpacing: 6.0,
children: _mainStoragePaths
.map(
(e) => NamidaInkWell(
animationDurationMS: 200,
borderRadius: 8.0,
bgColor: _currentFolderPath.startsWith(e) ? theme.colorScheme.secondaryContainer : theme.cardColor,
onTap: () => _fetchFiles(Directory(e)),
margin: const EdgeInsets.symmetric(horizontal: 4.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
e,
style: textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w600),
),
),
)
.toList(),
),
),
),
if (pathSplitsChildren.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
child: SizedBox(
width: context.width,
child: SmoothSingleChildScrollView(
controller: _pathSplitsScrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: pathSplitsChildren,
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
child: Row(
children: [
if (_currentFolders.isNotEmpty || _currentFiles.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0),
child: Text(
[
if (_currentFolders.isNotEmpty) _currentFolders.length.displayFolderKeyword,
if (_currentFiles.isNotEmpty) _currentFiles.length.displayFilesKeyword,
].join(' | '),
style: textTheme.displayMedium,
),
),
const Spacer(),
Obx(
(context) => SortByMenu(
title: _sortTypeToName[settings.fileBrowserSort.valueR] ?? '',
popupMenuChild: SortByMenuCustom(
childrenCallback: (context) {
Widget getTile(IconData icon, String title, FileBrowserSortType sort) {
return ObxO(
rx: settings.fileBrowserSort,
builder: (context, currentSort) => SmallListTile(
borderRadius: 12.0,
visualDensity: const VisualDensity(horizontal: -4, vertical: -3.5),
trailing: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Icon(icon, size: 20.0),
),
title: title,
active: currentSort == sort,
onTap: () => _sortItems(sort, null),
),
);
}
return [
Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0, bottom: 4.0),
child: ListTileWithCheckMark(
borderRadius: 10.0,
activeRx: settings.fileBrowserSortReversed,
onTap: () => _sortItems(null, !settings.fileBrowserSortReversed.value),
),
),
getTile(Broken.text, lang.fileName, FileBrowserSortType.name),
getTile(Broken.calendar, lang.date, FileBrowserSortType.dateModified),
getTile(Broken.document_code, lang.extension, FileBrowserSortType.type),
getTile(Broken.math, lang.size, FileBrowserSortType.size),
];
},
),
isCurrentlyReversed: settings.fileBrowserSortReversed.valueR,
onReverseIconTap: () => _sortItems(null, !settings.fileBrowserSortReversed.value),
),
),
const SizedBox(width: 8.0),
],
),
),
Expanded(
child: Stack(
children: [
Listener(
onPointerMove: (event) {
onPointerMove(_scrollController, event);
},
onPointerUp: (event) async {
onRefresh(() async => await _fetchFiles(Directory(_currentFolderPath), clearPrevious: false));
},
onPointerCancel: (event) => onVerticalDragFinish(),
child: _isFetching
? Center(
key: const Key('loading'),
child: ThreeArchedCircle(
color: theme.colorScheme.primary.withOpacityExt(0.5),
size: 56.0,
),
)
: _currentFolders.isEmpty && _currentFiles.isEmpty
? SizedBox(
width: context.width,
child: Column(
key: const Key('empty'),
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Broken.emoji_sad,
size: 42.0,
),
const SizedBox(height: 12.0),
Text(
"0 ${lang.files}",
style: textTheme.displayLarge,
),
],
),
)
: AnimationLimiter(
key: const Key('items'),
child: NamidaScrollbar(
controller: _scrollController,
child: SmoothCustomScrollView(
controller: _scrollController,
slivers: [
SuperSliverList.builder(
itemCount: _currentFolders.length,
itemBuilder: (context, index) {
final folder = _currentFolders[index];
final info = _currentInfoDirs[folder.path];
if (info == null && !_fetchingInfo && !_showEmptyFolders) return const SizedBox();
return _FileSystemChip(
position: index,
bgColor: chipColor,
onTap: () => _onFolderTap(folder),
onLongPress: () => _onFolderLongPress(folder),
displayCheckMark: _selectedFolders.isNotEmpty,
selected: _selectedFoldersLookup[folder.path] == true,
icon: Broken.folder,
title: _pathToName(folder.path),
subtitle: info == null
? 0.fileSizeFormatted
: [
"${info.size.fileSizeFormatted}${info.accurate ? '' : '?'}",
if (info.filesCount > 0) "${info.filesCount.displayFilesKeyword}${info.accurate ? '' : '?'}",
if (info.foldersCount > 0) "${info.foldersCount.displayFolderKeyword}${info.accurate ? '' : '?'}",
].join(' | '),
);
},
),
if (_currentFolders.isNotEmpty && _currentFiles.isNotEmpty)
const SliverToBoxAdapter(
child: NamidaContainerDivider(
margin: EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0),
),
),
SuperSliverList.builder(
itemCount: _currentFiles.length,
itemBuilder: (context, index) {
final file = _currentFiles[index];
final info = _currentInfoFiles[file.path];
final image = _getFileImage(file);
return _FileSystemChip(
position: index + _currentFolders.length + 1,
bgColor: chipColor,
onTap: () => _onFileTap(file),
onLongPress: () => _onFileLongPress(file),
displayCheckMark: _selectedFiles.isNotEmpty,
selected: _selectedFilesLookup[file.path] == true,
icon: image == null ? _fileToIcon(file) : null,
leading: image != null ? _getFileImage(file) : null,
title: _pathToName(file.path),
subtitle: info == null ? '' : "${info.size.fileSizeFormatted} | ${info.modified.millisecondsSinceEpoch.dateAndClockFormattedOriginal}",
);
},
),
],
),
),
),
),
pullToRefreshWidget,
Positioned(
bottom: 12.0,
right: 12.0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: T == File && _selectedFiles.isNotEmpty
? FloatingActionButton.extended(
heroTag: 'file_browser_fab_hero_extended',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12.0),
onPressed: () => _onSelectionComplete(_selectedFiles as List<T>),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Broken.tick_square,
size: 24.0,
),
const SizedBox(width: 8.0),
Text(_selectedFiles.length.displayFilesKeyword),
],
),
)
: T == Directory && (_selectedFolders.isNotEmpty || !isPathRoot(_currentFolderPath))
? FloatingActionButton(
heroTag: 'file_browser_fab_hero',
onPressed: () => _onSelectionComplete(
_selectedFolders.isNotEmpty ? _selectedFolders as List<T> : [Directory(_currentFolderPath) as T],
),
child: const Icon(
Broken.tick_square,
size: 32.0,
color: AppThemes.fabForegroundColor,
),
)
: const SizedBox(),
),
),
],
),
),
],
),
),
),
);
}
}
class _FileSystemChip extends StatelessWidget {
final String title;
final String subtitle;
final IconData? icon;
final Color bgColor;
final VoidCallback onTap;
final VoidCallback onLongPress;
final int position;
final bool displayCheckMark;
final bool selected;
final Widget? leading;
const _FileSystemChip({
required this.title,
required this.subtitle,
required this.icon,
required this.bgColor,
required this.onTap,
required this.onLongPress,
required this.position,
required this.displayCheckMark,
required this.selected,
this.leading,
});
@override
Widget build(BuildContext context) {
final textTheme = context.textTheme;
return NamidaInkWell(
borderRadius: 8.0,
bgColor: bgColor,
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
onTap: onTap,
onLongPress: onLongPress,
child: Row(
children: [
leading ?? Icon(icon),
const SizedBox(width: 8.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: textTheme.displaySmall?.copyWith(
fontSize: 13.0,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2.0),
Text(
subtitle,
style: textTheme.displaySmall?.copyWith(
fontSize: 12.0,
fontWeight: FontWeight.w400,
// color: theme.colorScheme.onSurface.withOpacityExt(0.7),
),
),
],
),
),
const SizedBox(width: 4.0),
NamidaCheckMark(
size: 16.0,
active: selected,
).animateEntrance(
showWhen: displayCheckMark,
durationMS: 200,
),
],
),
);
}
}
class NamidaFileStat {
final int size;
final DateTime accessed;
final DateTime changed;
final DateTime modified;
const NamidaFileStat({
required this.size,
required this.accessed,
required this.changed,
required this.modified,
});
}
class NamidaDirStat {
final bool accurate;
final int filesCount;
final int foldersCount;
final int size;
final DateTime accessed;
final DateTime changed;
final DateTime modified;
const NamidaDirStat({
required this.accurate,
required this.filesCount,
required this.foldersCount,
required this.size,
required this.accessed,
required this.changed,
required this.modified,
});
}