mirror of
https://github.com/namidaco/namida.git
synced 2026-03-13 08:12:29 +08:00
1525 lines
56 KiB
Dart
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,
|
|
});
|
|
}
|