first commit.

This commit is contained in:
2026-07-12 03:39:51 +08:00
commit 404d2a9140
52 changed files with 3041 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import 'dart:io';
import 'package:cloud_archiver/utils.dart';
import 'dart:convert' show json;
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' show join;
import 'package:uuid/uuid.dart';
late File _configFile;
AppConfig? appConfig;
class AppConfig {
final String client;
final String server;
final String user;
final String passwd;
final List<SyncItemConfig> syncItems;
static Future<void> init() async {
var directory = await getApplicationSupportDirectory();
_configFile = File(join(directory.path, 'config.json'));
if (_configFile.existsSync()) {
try {
appConfig = AppConfig.fromJson(json.decode(_configFile.readAsStringSync()));
} catch (e) {
print(e);
}
}
}
static void updateConfig({
required String client,
required String server,
required String user,
required String passwd,
}) {
appConfig = AppConfig(
client: client,
server: server,
user: user,
passwd: passwd,
syncItems: appConfig?.syncItems ?? [],
);
_save();
}
static void addSyncItem(SyncItemConfig item) {
var items = (appConfig?.syncItems ?? []).where((ele) => ele.uuid != item.uuid);
appConfig = AppConfig(
client: appConfig?.client ?? '',
server: appConfig?.server ?? '',
user: appConfig?.user ?? '',
passwd: appConfig?.passwd ?? '',
syncItems: [
item,
...items,
],
);
_save();
loadLocalStatus();
}
static void removeSyncItem(SyncItemConfig item) {
var items = (appConfig?.syncItems ?? []).where((ele) => ele.uuid != item.uuid);
appConfig = AppConfig(
client: appConfig?.client ?? '',
server: appConfig?.server ?? '',
user: appConfig?.user ?? '',
passwd: appConfig?.passwd ?? '',
syncItems: items.toList(),
);
_save();
loadLocalStatus();
}
AppConfig({
required this.client,
required this.server,
required this.user,
required this.passwd,
required this.syncItems,
});
factory AppConfig.fromJson(Map<String, dynamic> json) => AppConfig(
client: json['client'],
server: json['server'],
user: json['user'],
passwd: json['passwd'],
syncItems: (json['syncItems'] as List? ?? []).map((ele) => SyncItemConfig.fromJson(ele)).toList(),
);
Map<String, dynamic> toJson() => {
'client': client,
'server': server,
'user': user,
'passwd': passwd,
'syncItems': syncItems,
};
static void _save() {
_configFile.writeAsStringSync(json.encode(appConfig));
}
}
class SyncItemConfig {
final String uuid;
final String name;
final String path;
SyncItemConfig({required this.name, required this.path, String? uuid}) : uuid = uuid ?? Uuid().v4();
factory SyncItemConfig.fromJson(Map<String, dynamic> json) => SyncItemConfig(
uuid: json['uuid'],
name: json['name'],
path: json['path'],
);
Map<String, dynamic> toJson() => {
'uuid': uuid,
'name': name,
'path': path,
};
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:cloud_archiver/config.dart';
import 'package:cloud_archiver/utils.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
class AppConfigDialog extends StatefulWidget {
const AppConfigDialog({super.key});
static void show(BuildContext context) {
showDialog(
context: context,
builder: (context) => Dialog(
child: AppConfigDialog(),
),
);
}
@override
State<AppConfigDialog> createState() => _AppConfigDialogState();
}
class _AppConfigDialogState extends State<AppConfigDialog> {
final TextEditingController clientCtl = TextEditingController();
final TextEditingController serverCtl = TextEditingController();
final TextEditingController userCtl = TextEditingController();
final TextEditingController passwdCtl = TextEditingController();
@override
void initState() {
super.initState();
if (appConfig != null) {
clientCtl.text = appConfig!.client;
serverCtl.text = appConfig!.server;
userCtl.text = appConfig!.user;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: 400,
height: 300,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black26,
offset: Offset(4, 4),
spreadRadius: 8,
blurRadius: 16,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'配置DAV服务',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
children: [
SizedBox(width: 70, child: Text('标识名: ')),
Flexible(
child: TextField(
controller: clientCtl,
),
),
],
),
Row(
children: [
SizedBox(width: 70, child: Text('服务器: ')),
Flexible(
child: TextField(
controller: serverCtl,
),
),
],
),
Row(
children: [
SizedBox(width: 70, child: Text('用户名: ')),
Flexible(
child: TextField(
controller: userCtl,
),
),
],
),
Row(
children: [
SizedBox(width: 70, child: Text('密码: ')),
Flexible(
child: TextField(
controller: passwdCtl,
),
),
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('取消'),
),
ElevatedButton(
onPressed: () {
var client = clientCtl.text.trim();
var server = serverCtl.text.trim();
var user = userCtl.text.trim();
var passwd = passwdCtl.text.trim();
connect(server, user, passwd).then(
(connected) {
if (connected) {
AppConfig.updateConfig(
client: client,
server: server,
user: user,
passwd: passwd,
);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
);
},
child: Text('确认'),
),
],
),
],
),
);
}
}
class AddSyncItemDialog extends StatefulWidget {
final SyncItemConfig? editItemConfig;
const AddSyncItemDialog({super.key, this.editItemConfig});
static void show(BuildContext context) {
showDialog(
context: context,
builder: (context) => Dialog(
child: AddSyncItemDialog(),
),
);
}
@override
State<AddSyncItemDialog> createState() => _AddSyncItemDialogState();
}
class _AddSyncItemDialogState extends State<AddSyncItemDialog> {
final TextEditingController nameCtl = TextEditingController();
final TextEditingController pathCtl = TextEditingController();
@override
Widget build(BuildContext context) {
return Container(
width: 400,
height: 300,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black26,
offset: Offset(4, 4),
spreadRadius: 8,
blurRadius: 16,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.editItemConfig == null ? '添加同步项' : '修改同步项',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
children: [
SizedBox(width: 80, child: Text('项目名称: ')),
Flexible(
child: TextField(
controller: nameCtl,
),
),
],
),
Row(
children: [
SizedBox(width: 80, child: Text('项目路径: ')),
Flexible(
child: TextField(
controller: pathCtl,
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () {
FilePicker.pickFiles(
allowMultiple: false,
).then(
(res) {
if (res?.files.firstOrNull?.path?.isNotEmpty == true) {
pathCtl.text = res!.files.first.path!;
}
},
);
},
icon: Icon(Icons.file_open_rounded),
),
IconButton(
onPressed: () {
FilePicker.getDirectoryPath().then(
(path) {
if (path?.isNotEmpty == true) {
pathCtl.text = path!;
}
},
);
},
icon: Icon(Icons.folder_open_rounded),
),
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('取消'),
),
ElevatedButton(
onPressed: () {
var name = nameCtl.text.trim();
var path = pathCtl.text.trim();
if (name.isNotEmpty && path.isNotEmpty) {
AppConfig.addSyncItem(
SyncItemConfig(
name: name,
path: path,
uuid: widget.editItemConfig?.uuid,
),
);
Navigator.of(context).pop();
}
},
child: Text('确认'),
),
],
),
],
),
);
}
}
+262
View File
@@ -0,0 +1,262 @@
import 'package:cloud_archiver/config.dart';
import 'package:cloud_archiver/dialog.dart';
import 'package:cloud_archiver/state.dart';
import 'package:cloud_archiver/utils.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart';
import 'package:webdav_client/webdav_client.dart' as dav;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppConfig.init();
if (appConfig?.server.isNotEmpty == true) {
connect(appConfig!.server, appConfig!.user, appConfig!.passwd).then(
(success) {
if (success) {
loadCloudArchives();
}
},
);
}
if (appConfig?.syncItems.isNotEmpty == true) {
loadLocalStatus();
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Cloud Archiver',
theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
var width = MediaQuery.sizeOf(context).width;
int rows = (width ~/ 800) + 1;
double itemWidth = width / rows;
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
leading: IconButton(
onPressed: () {
AddSyncItemDialog.show(context);
},
icon: Icon(Icons.add),
),
title: ValueListenableBuilder(
valueListenable: davStatus,
builder: (context, value, child) => Center(
child: switch (value) {
DavStatus.connected => Text('总占用: ${totalSize()}'),
DavStatus.loading => Text('加载中...'),
DavStatus.notConnected => Text('未连接服务器'),
},
),
),
actions: [
IconButton(
onPressed: () {
loadCloudArchives();
},
icon: Icon(Icons.refresh_outlined),
),
IconButton(
onPressed: () {
AppConfigDialog.show(context);
},
icon: Icon(Icons.settings),
),
],
),
body: ValueListenableBuilder(
valueListenable: localSyncItems,
builder: (context, localSyncItems, child) {
return ValueListenableBuilder(
valueListenable: cloudArchives,
builder: (context, cloudArchives, child) {
var itemList = genItemList(localSyncItems, {...cloudArchives});
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: rows,
childAspectRatio: itemWidth / 80,
),
itemBuilder: (context, index) {
var (name, syncItemConfig, localModifiedTime, cloudArchives) = itemList[index];
var cloudModifiedTimeTs = cloudArchives.map((archive) => archive.mTime?.millisecondsSinceEpoch ?? 0).maxOrNull;
return Container(
margin: EdgeInsets.all(8),
padding: EdgeInsets.only(left: 10, right: 10, top: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black26,
offset: Offset(1, 1),
spreadRadius: 2,
blurRadius: 5,
),
],
),
height: 80,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
name,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, height: 1),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.videogame_asset_outlined, size: 18),
SizedBox(width: 4),
Flexible(child: Text(localModifiedTime?.strHm ?? '未同步')),
],
),
),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_outlined, size: 18),
SizedBox(width: 4),
Flexible(
child: Text(
cloudModifiedTimeTs == null ? '未上传' : DateTime.fromMillisecondsSinceEpoch(cloudModifiedTimeTs).strHm,
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: localModifiedTime == null
? null
: () {
if (syncItemConfig != null) {
upload(syncItemConfig, cloudModifiedTimeTs == null);
}
},
icon: Icon(Icons.upload_rounded, size: 22),
),
Builder(
builder: (ctx) {
return IconButton(
onPressed: cloudModifiedTimeTs == null
? null
: () {
if (syncItemConfig != null) {
download(syncItemConfig, cloudArchives.sorted.first);
}
},
onLongPress: cloudModifiedTimeTs == null
? null
: () {
var btn = (ctx.findRenderObject() as RenderBox);
var offset = btn.localToGlobal(Offset(btn.size.width, btn.size.height));
showMenu<dav.File>(
context: ctx,
position: RelativeRect.fromLTRB(offset.dx - 300, offset.dy, width - offset.dx, offset.dy + 200),
items: cloudArchives.sorted.map(
(file) {
var parts = basenameWithoutExtension(file.name ?? '').split('-');
parts.removeLast();
var client = parts.join('-');
return PopupMenuItem<dav.File>(
value: file,
onTap: () {
if (syncItemConfig != null) {
download(syncItemConfig, file);
}
},
child: SizedBox(
width: 300,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
client,
overflow: TextOverflow.ellipsis,
),
),
Text((file.mTime ?? file.cTime ?? DateTime(0)).strHms),
],
),
),
);
},
).toList(),
);
},
icon: Icon(Icons.download_rounded, size: 22),
);
},
),
],
),
],
),
],
),
);
},
itemCount: itemList.length,
);
},
);
},
),
);
}
List<(String, SyncItemConfig?, DateTime?, List<dav.File>)> genItemList(
Map<SyncItemConfig, DateTime?> localSyncItems,
Map<String, List<dav.File>> cloudArchives,
) {
var result = <(String, SyncItemConfig?, DateTime?, List<dav.File>)>[];
var items = localSyncItems.entries.toList()..sort((a, b) => (a.value?.millisecondsSinceEpoch ?? 0).compareTo(b.value?.millisecondsSinceEpoch ?? 0));
for (var item in items) {
var archive = cloudArchives.remove(item.key.name);
result.add((item.key.name, item.key, item.value, archive ?? []));
}
for (var archive in cloudArchives.entries) {
result.add((archive.key, null, null, archive.value));
}
return result;
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:cloud_archiver/config.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:webdav_client/webdav_client.dart' as dav;
enum DavStatus {
notConnected,
connected,
loading,
}
extension CloudArchivesSortExt on List<dav.File> {
List<dav.File> get sorted => sortedByCompare(
(file) => file.name ?? '',
(a, b) => b.split('-').last.compareTo(a.split('-').last),
);
}
final ValueNotifier<DavStatus> davStatus = ValueNotifier(DavStatus.notConnected);
final ValueNotifier<Map<String, List<dav.File>>> cloudArchives = ValueNotifier({});
final ValueNotifier<Map<SyncItemConfig, DateTime?>> localSyncItems = ValueNotifier({});
+174
View File
@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:cloud_archiver/config.dart';
import 'package:cloud_archiver/state.dart';
import 'package:filesize/filesize.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:webdav_client/webdav_client.dart' as dav;
late dav.Client _client;
String totalSize() {
if (cloudArchives.value.isEmpty) return '0 MB';
return filesize(
cloudArchives.value.values
.map((archives) => archives.map((archive) => archive.size ?? 0).reduce((total, size) => total + size))
.reduce((total, size) => total + size),
1,
);
}
Future<bool> connect(String server, String user, String auth) async {
var client = dav.newClient(
server,
debug: true,
user: user,
password: auth,
);
client.setHeaders({'accept-charset': 'utf-8'});
client.setConnectTimeout(5 * 1000);
// Set send data timeout time in milliseconds.
client.setSendTimeout(60 * 1000);
// Set transfer data time in milliseconds.
client.setReceiveTimeout(60 * 1000);
try {
davStatus.value = DavStatus.loading;
await client.ping();
davStatus.value = DavStatus.connected;
_client = client;
return true;
} catch (e) {
print('$e');
davStatus.value = DavStatus.notConnected;
}
return false;
}
void loadLocalStatus() {
if (appConfig?.syncItems.isNotEmpty != true) return;
var result = <SyncItemConfig, DateTime?>{};
for (var syncItem in appConfig!.syncItems) {
var file = File(syncItem.path);
var stat = file.statSync();
switch (stat.type) {
case FileSystemEntityType.file:
result[syncItem] = stat.modified;
case FileSystemEntityType.directory:
result[syncItem] = dirModifiedTime(syncItem.path);
default:
result[syncItem] = null;
}
}
localSyncItems.value = {
...localSyncItems.value,
...result,
};
}
DateTime dirModifiedTime(String dir) {
var directory = Directory(dir);
var modified = directory.statSync().modified;
for (var entry in Directory(dir).listSync(recursive: true)) {
var stat = entry.statSync();
if (stat.type == FileSystemEntityType.file) {
if (stat.modified.isAfter(modified)) {
modified = stat.modified;
}
}
}
return modified;
}
void loadCloudArchives() async {
if (davStatus.value != DavStatus.connected) return;
davStatus.value = DavStatus.loading;
List<dav.File> list = [];
try {
list = await _client.readDir('/');
} catch (e) {
print(e);
davStatus.value = DavStatus.notConnected;
return;
}
var result = <String, List<dav.File>>{};
for (var item in list) {
if (item.isDir == true && item.path?.isNotEmpty == true) {
var files = await _client.readDir(item.path!);
result[basename(item.path!)] = files;
}
}
cloudArchives.value = result;
davStatus.value = DavStatus.connected;
}
Future<void> upload(SyncItemConfig item, bool needCreateDir) async {
if (needCreateDir) {
await _client.mkdir(item.name);
}
var file = File(item.path);
var cacheDir = await getApplicationCacheDirectory();
var zipFileEncoder = ZipFileEncoder();
var zipFileName = '${appConfig?.client ?? 'DEBUG'}-${DateTime.now().strFileName}.zip';
var zipTempFilePath = join(cacheDir.path, zipFileName);
zipFileEncoder.create(zipTempFilePath);
if (file.statSync().type == FileSystemEntityType.directory) {
await zipFileEncoder.addDirectory(Directory(item.path));
} else {
await zipFileEncoder.addFile(file);
}
await zipFileEncoder.close();
await _client.writeFromFile(zipTempFilePath, join(item.name, zipFileName));
File(zipTempFilePath).delete();
loadCloudArchives();
}
Future<void> download(SyncItemConfig item, dav.File cloudArchive) async {
var cacheDir = await getApplicationCacheDirectory();
var zipPath = join(cacheDir.path, '${item.name}_${cloudArchive.name}');
await _client.read2File(cloudArchive.path!, zipPath);
await extractFileToDisk(zipPath, cacheDir.path);
File(zipPath).delete();
var temp = File(join(cacheDir.path, basename(item.path)));
switch (temp.statSync().type) {
case FileSystemEntityType.directory:
var directory = Directory(item.path);
if (directory.existsSync()) {
directory.deleteSync(recursive: true);
}
Directory(temp.path);
Directory(temp.path).renameSync(item.path);
case FileSystemEntityType.file:
var file = File(item.path);
if (file.existsSync()) {
file.deleteSync();
}
temp.renameSync(item.path);
default:
}
}
extension DateTimeExt on DateTime {
String get strHm => '$year/${padZero(month)}/${padZero(day)} ${padZero(hour)}:${padZero(minute)}';
String get strHms => '$strHm:${padZero(second)}';
String get strFileName => '${year}_${padZero(month)}_${padZero(day)}_${padZero(hour)}_${padZero(minute)}_${padZero(second)}';
String padZero(int num, [fix = 2]) => num.toString().padLeft(fix, '0');
}