1:基本页面展示

项目地址:https://github.com/NeverOvO/never_filling

2:功能介绍

此程序的主要目的是用来归档文件夹中的不同类型的文件,通过命令行来转移归档文件

(1)源文件与目标文件夹选择,支持拖动文件夹来选择

(2)点击 “检索文件地址目录” 即可展示出该文件夹下的文件,包含子文件夹的子文件,无论几层都可以解析出来。

(3)可以选择重名文件是否保留,如果选择保留的话,会在全部文件后面添加需要,例如 Text.txt ==> Text_1.txt

(4)点击 “操作已选择文件” 就会生成命令行语句并直接复制到剪切板。例如:

mv '/Users/XXX/本地文稿/never_filling/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png' '/Users/XXX/Downloads/未命名文件夹/app_icon_16.png' ;

(5)用户确认起始位置无误后,进入终端复制操作即可。

3:部分代码解析记录

3.1 新版本功能

(1)在2023年2月14日优化了重复文件名的体验,目前不在需要选择是否保留重名文件,而是全部进行保留,并在全部重名文件后添加时间戳来进行区别。

//重名文件将会直接保留,在后续会添加时间戳保证非重复。
command = "mv '${totalEnd.first[2]}/${totalEnd.first[0]}' '$toAdd/${totalEnd.first[0]}'";
endFileName.add(totalEnd.first[0]);
for(int i = 1;i<totalEnd.length ; i++){
  if(endFileName.contains(totalEnd[i][0])){
    command += " ; mv '${totalEnd[i][2]}/${totalEnd[i][0]}' '$toAdd/${totalEnd[i][0].toString().replaceAll(".", "_${DateTime.now().microsecondsSinceEpoch.toString()}.")}'";
  }else{
    command += " ; mv '${totalEnd[i][2]}/${totalEnd[i][0]}' '$toAdd/${totalEnd[i][0]}'";
    endFileName.add(totalEnd[i][0]);
  }
}

(2)新增了黑名单功能,采用sqflite_common_ffi: ^2.2.1+1 实现,能拥有更好的扩展性与持续性。使用鼠标右键点击检索后的文件列表确认即可加入黑名单,后续的检索中黑名单文件将不会出现在检索列表内,避免操作与重复劳动。核心代码:

//打开数据库代码
db = await databaseFactory.openDatabase("blackList.db");
//检查表是否存在
var sql ="SELECT * FROM sqlite_master WHERE TYPE = 'table' AND NAME = 'Black'";
var res = await db.rawQuery(sql);
var returnRes = res!=null && res.length > 0;

if(!returnRes){
  await db.execute(
      '''
      CREATE TABLE Black (
          id INTEGER PRIMARY KEY,
          title TEXT,
          time TEXT,
          other TEXT
      )
      '''
  );
}

//向数据库中插入数据
await db.insert('Black', {'title': fileLookupList![index][0] , 'time': DateTime.now().toString().split(".").first,'other' : ""});

3.2 旧版本功能代码

(0)该项目的全部依赖第三方库:

  cupertino_icons: ^1.0.5
  path_provider: ^2.0.11
  file_picker: ^5.2.4
  mime: ^1.0.3 #判断文件类型
  desktop_drop: ^0.4.0 #文件拖动

(1)对于文件夹的判断

此处用到的库为:mime: ^1.0.3

lookupMimeType(fileSystemEntity.path) != null

即在看文件类型时,如果lookupMimeType值为null,那么可以判断为这个文件为文件夹,这个判断语句基本可以判断出大部分常用文件类型,完整的 “检索源文件夹目录” 核心代码:

await for(FileSystemEntity fileSystemEntity in fileList!){
  if(lookupMimeType(fileSystemEntity.path) != null){
    if(!fileSystemEntity.path.toString().split("/").last.startsWith(".")){
      fileLookupType![lookupMimeType(fileSystemEntity.path)] = "true";
      fileLookupList!.add([fileSystemEntity.path.toString().split("/").last,lookupMimeType(fileSystemEntity.path),fileSystemEntity.parent.path]);
      total = fileLookupList!.length;
    }
  }
}

这里我选择将文件的路径与文件的实际名字分开保存,打算为后期的根本文件夹选择功能进行保留。

#目前已知问题,后期修复:

如果文件夹名字和文件名字相同,那么转移的时候会直接转移整个文件夹,内部子文件不受影响。

(2)文件夹选择与拖动选择

String? selectedDirectory = await FilePicker.platform.getDirectoryPath(dialogTitle:"选择源文件夹",lockParentWindow: true);
file_picker: ^5.2.4

这是采用file_picker来打开一个文件选择窗口,是较为传统与常用的方式。

下面是拖动文件夹到APP内,来快速选择文件夹的方法:

desktop_drop: ^0.4.0
DropTarget(
  onDragDone: (detail) {
    setState(() {
      if(lookupMimeType(detail.files.first.path) != null){
        setState(() {
          _errorFrom = true;
        });
        Future.delayed(const Duration(seconds: 2)).then((onValue) async{
          setState(() {
            _errorFrom = false;
          });
        });
        return;
      }else{
        fromDirectoryPathController.text = detail.files.first.path;
        setState(() {
          _errorFrom = false;
        });
      }
    });
  },
  onDragEntered: (detail) {
    setState(() {
      _dragging = true;
    });
  },
  onDragExited: (detail) {
    setState(() {
      _dragging = false;
    });
  },

核心点就是在onDragDone方法中展示。

完整部分:

DropTarget(
  onDragDone: (detail) {
    setState(() {
      if(lookupMimeType(detail.files.first.path) != null){
        setState(() {
          _errorFrom = true;
        });
        Future.delayed(const Duration(seconds: 2)).then((onValue) async{
          setState(() {
            _errorFrom = false;
          });
        });
        return;
      }else{
        fromDirectoryPathController.text = detail.files.first.path;
        setState(() {
          _errorFrom = false;
        });
      }
    });
  },
  onDragEntered: (detail) {
    setState(() {
      _dragging = true;
    });
  },
  onDragExited: (detail) {
    setState(() {
      _dragging = false;
    });
  },
  child: Container(
    color: _dragging ? Colors.blue.withOpacity(0.4) : Colors.transparent,
    child:Container(
      padding: const EdgeInsets.fromLTRB(10, 20, 10, 20),
      child: Row(
        children: [
          Expanded(
            flex: 2,
            child: TextField(
              enabled: false,
              decoration: const InputDecoration(
                contentPadding: EdgeInsets.fromLTRB(10, 0, 10, 0),
                enabledBorder: UnderlineInputBorder(),
                labelStyle: TextStyle(color: Colors.grey),
                labelText: '请选择源文件夹地址或拖动到此处',
              ),
              controller: fromDirectoryPathController,
              autocorrect:false,
              style: const TextStyle(color: Colors.black,fontSize: 11),
            ),
          ),
          GestureDetector(
            onTap: () async{
              if(_filePicktrue){
                _filePicktrue = false;
                String? selectedDirectory = await FilePicker.platform.getDirectoryPath(dialogTitle:"选择源文件夹",lockParentWindow: true);
                if(selectedDirectory != null){
                  command = "";
                  total = 0;
                  fileLookupList!.clear();
                  fileLookupType!.clear();
                  fromDirectoryPathController.text = selectedDirectory;
                  setState(() {});
                }
                _filePicktrue = true;
              }
            },
            behavior: HitTestBehavior.opaque,
            child: Container(
              decoration: const BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.all(Radius.circular(6.0)),
              ),
              margin: const EdgeInsets.fromLTRB(10, 0, 10, 0),
              alignment: Alignment.centerLeft,
              padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
              child: const Text("选择",style: TextStyle(color: Colors.white),),
            ),
          ),
        ],
      ),
    ),
  ),
),

(3)在终端内执行的文件移动语句生成:

这里我只写了macOS下的方法,理论上win和macOS对于命令行语句在写法上应该是不同的,所以该APP目前只能应用在macOS中,win后续等有空了再写。

List? totalEnd = [];
fileLookupType!.forEach((key, value) {
  if(value == "true"){
    totalEnd.addAll(fileLookupList!.where((element) => element[1] == key));
  }
});

command = "";
if(totalEnd.isNotEmpty){
  if(totalEnd.length == 1){
    command = "mv '${totalEnd.first[2]}/${totalEnd.first[0]}' '${toDirectoryPathController.text}/${totalEnd.first[0]}'";
  }else{

    if(repeat == 1){
      command = "mv '${totalEnd.first[2]}/${totalEnd.first[0]}' '${toDirectoryPathController.text}/${totalEnd.first[0].toString().replaceAll(".", "_0.")}'";
      for(int i = 1;i<totalEnd.length ; i++){
        command += " ; mv '${totalEnd[i][2]}/${totalEnd[i][0]}' '${toDirectoryPathController.text}/${totalEnd[i][0].toString().replaceAll(".", "_${i.toString()}.")}'";
      }
    }else{
      command = "mv '${totalEnd.first[2]}/${totalEnd.first[0]}' '${toDirectoryPathController.text}/${totalEnd.first[0]}'";
      for(int i = 1;i<totalEnd.length ; i++){
        command += " ; mv '${totalEnd[i][2]}/${totalEnd[i][0]}' '${toDirectoryPathController.text}/${totalEnd[i][0]}'";
      }
    }
  }
}

解释:先将需要移动的所有文件汇总,排除非选中项目,判断list是否大于1,来区别1项文件与多项文件时语句的不同拼接方法。

若用户选择保留重名文件的话,会在文件名称后面添加当前的编号.replaceAll(".", "_${i.toString()}.")。