generated from dellevin/template
完善2
This commit is contained in:
@@ -5,7 +5,7 @@ import '../providers/app_provider.dart';
|
||||
import '../utils/backup_service.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 数据备份页面
|
||||
/// 本地备份页面
|
||||
class BackupPage extends StatefulWidget {
|
||||
const BackupPage({super.key});
|
||||
|
||||
@@ -22,7 +22,7 @@ class _BackupPageState extends State<BackupPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('数据备份'),
|
||||
title: const Text('本地备份'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
|
||||
/// 添加/编辑书籍页面 - 紧凑双行布局设计
|
||||
class BookFormPage extends StatefulWidget {
|
||||
@@ -649,18 +650,22 @@ class _BookFormPageState extends State<BookFormPage> {
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'book_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final savedPath = path.join(appDir.path, 'book_covers', fileName);
|
||||
// 生成文件名
|
||||
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
final coverDir = Directory(path.join(appDir.path, 'book_covers'));
|
||||
if (!await coverDir.exists()) {
|
||||
await coverDir.create(recursive: true);
|
||||
}
|
||||
// 如果是编辑模式,使用现有书籍ID;如果是新建模式,使用临时ID(保存时会替换)
|
||||
final bookId = widget.book?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
await File(pickedFile.path).copy(savedPath);
|
||||
// 保存到新的路径结构: images/books/{bookId}/{fileName}
|
||||
final targetPath = await ImagePathHelper.instance.getBookCoverPath(
|
||||
bookId,
|
||||
fileName
|
||||
);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
|
||||
setState(() => _coverPath = savedPath);
|
||||
await File(pickedFile.path).copy(targetPath);
|
||||
|
||||
setState(() => _coverPath = targetPath);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -682,10 +687,19 @@ class _BookFormPageState extends State<BookFormPage> {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (widget.book == null) {
|
||||
// 生成新的书籍ID
|
||||
final newBookId = now.millisecondsSinceEpoch.toString();
|
||||
|
||||
// 如果有封面,需要移动到正确的ID目录
|
||||
String? finalCoverPath;
|
||||
if (_coverPath != null && _coverPath!.isNotEmpty) {
|
||||
finalCoverPath = await _moveCoverToNewId(_coverPath!, newBookId);
|
||||
}
|
||||
|
||||
final newBook = Book(
|
||||
id: now.millisecondsSinceEpoch.toString(),
|
||||
id: newBookId,
|
||||
title: _titleController.text.trim(),
|
||||
coverPath: _coverPath,
|
||||
coverPath: finalCoverPath,
|
||||
authors: _authors,
|
||||
alternateTitles: _alternateTitles,
|
||||
publisher: _publisherController.text.trim(),
|
||||
@@ -721,4 +735,45 @@ class _BookFormPageState extends State<BookFormPage> {
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// 将封面从临时ID目录移动到新的书籍ID目录
|
||||
Future<String?> _moveCoverToNewId(String currentPath, String newBookId) async {
|
||||
// 检查是否已经在正确的目录中(兼容 Windows 路径分隔符)
|
||||
final normalizedPath = currentPath.replaceAll('\\', '/');
|
||||
if (normalizedPath.contains('/books/$newBookId/')) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// 提取文件名
|
||||
final fileName = p.basename(currentPath);
|
||||
|
||||
// 获取新路径
|
||||
final newPath = await ImagePathHelper.instance.getBookCoverPath(
|
||||
newBookId,
|
||||
fileName
|
||||
);
|
||||
|
||||
// 确保目标目录存在
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath));
|
||||
|
||||
// 移动文件
|
||||
final currentFile = File(currentPath);
|
||||
if (await currentFile.exists()) {
|
||||
await currentFile.rename(newPath);
|
||||
|
||||
// 删除空的临时目录
|
||||
final tempDir = Directory(p.dirname(currentPath));
|
||||
if (await tempDir.exists()) {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (e) {
|
||||
// 忽略删除目录失败的情况
|
||||
}
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,108 +106,111 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
|
||||
}
|
||||
|
||||
Widget _buildReviewItem(BookReview review) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部:类型标签 + 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFFF5F5F5)
|
||||
: const Color(0xFF1A1A1A),
|
||||
),
|
||||
child: Text(
|
||||
review.typeText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
return InkWell(
|
||||
onLongPress: () => _showDeleteDialog(review),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部:类型标签 + 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFF666666)
|
||||
: Colors.white,
|
||||
? const Color(0xFFF5F5F5)
|
||||
: const Color(0xFF1A1A1A),
|
||||
),
|
||||
child: Text(
|
||||
review.typeText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFF666666)
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 编辑按钮
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToEditReview(review),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 删除按钮
|
||||
GestureDetector(
|
||||
onTap: () => _showDeleteDialog(review),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 评论内容
|
||||
Text(
|
||||
review.content,
|
||||
maxLines: review.reviewType == 1 ? 3 : 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 底部信息
|
||||
Row(
|
||||
children: [
|
||||
if (review.reviewer.isNotEmpty) ...[
|
||||
Text(
|
||||
review.reviewer,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
const Spacer(),
|
||||
// 编辑按钮
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToEditReview(review),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 删除按钮
|
||||
GestureDetector(
|
||||
onTap: () => _showDeleteDialog(review),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (review.source.isNotEmpty) ...[
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 评论内容
|
||||
Text(
|
||||
review.content,
|
||||
maxLines: review.reviewType == 1 ? 3 : 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 底部信息
|
||||
Row(
|
||||
children: [
|
||||
if (review.reviewer.isNotEmpty) ...[
|
||||
Text(
|
||||
review.reviewer,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (review.source.isNotEmpty) ...[
|
||||
Text(
|
||||
'来源:${review.source}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
'来源:${review.source}',
|
||||
_formatDate(review.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDate(review.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'webdav_sync_page.dart';
|
||||
|
||||
/// 云同步主页面 - 选择同步方式
|
||||
/// 云备份主页面 - 选择备份方式
|
||||
class CloudSyncPage extends StatelessWidget {
|
||||
const CloudSyncPage({super.key});
|
||||
|
||||
@@ -10,17 +10,17 @@ class CloudSyncPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('云同步'),
|
||||
title: const Text('云备份'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// WebDAV 同步选项
|
||||
// WebDAV 备份选项
|
||||
_buildSyncOption(
|
||||
context,
|
||||
icon: Icons.storage_outlined,
|
||||
title: 'WebDAV 同步',
|
||||
subtitle: '通过 WebDAV 协议同步到个人云盘(如坚果云、Nextcloud 等)',
|
||||
title: 'WebDAV 备份',
|
||||
subtitle: '通过 WebDAV 协议备份到个人云盘(如坚果云、Nextcloud 等)',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -56,7 +56,7 @@ class CloudSyncPage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'关于云同步',
|
||||
'关于云备份',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -65,10 +65,10 @@ class CloudSyncPage extends StatelessWidget {
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'• 云同步可以将您的数据备份到远程服务器\n'
|
||||
'• 支持多台设备之间的数据同步\n'
|
||||
'• 建议定期进行云同步以确保数据安全\n'
|
||||
'• 首次同步可能需要较长时间,请保持网络连接',
|
||||
'• 云备份可以将您的数据备份到远程服务器\n'
|
||||
'• 支持多台设备之间的数据恢复\n'
|
||||
'• 建议定期进行云备份以确保数据安全\n'
|
||||
'• 首次备份可能需要较长时间,请保持网络连接',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
|
||||
@@ -7,12 +7,12 @@ import 'note_tab_page.dart';
|
||||
import 'search_page.dart';
|
||||
import 'webdav_sync_page.dart';
|
||||
import '../utils/webdav_service.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 云同步模式
|
||||
/// 云备份模式
|
||||
enum SyncMode {
|
||||
bidirectional, // 双向同步
|
||||
uploadOnly, // 仅上传
|
||||
downloadOnly, // 仅下载
|
||||
uploadOnly, // 上传
|
||||
downloadOnly, // 下载
|
||||
}
|
||||
|
||||
/// 主内容页 - 观影/阅读/笔记标签页
|
||||
@@ -44,11 +44,11 @@ class MainContentPage extends StatelessWidget {
|
||||
return AppBar(
|
||||
title: Text(_getAppBarTitle(provider)),
|
||||
actions: [
|
||||
// 云同步按钮
|
||||
// 云备份按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_sync_outlined),
|
||||
onPressed: () => _showCloudSyncDialog(context, provider),
|
||||
tooltip: '云同步',
|
||||
tooltip: '云备份',
|
||||
),
|
||||
// 搜索按钮
|
||||
IconButton(
|
||||
@@ -248,7 +248,7 @@ class MainContentPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'云同步',
|
||||
'云备份',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -260,27 +260,19 @@ class MainContentPage extends StatelessWidget {
|
||||
// 同步选项
|
||||
Column(
|
||||
children: [
|
||||
_buildSyncOption(
|
||||
context,
|
||||
icon: Icons.sync,
|
||||
iconColor: const Color(0xFF1A1A1A),
|
||||
title: '双向同步',
|
||||
subtitle: '本地和云端数据合并,冲突时以最新为准',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToSync(context, SyncMode.bidirectional);
|
||||
},
|
||||
),
|
||||
const Divider(height: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
|
||||
_buildSyncOption(
|
||||
context,
|
||||
icon: Icons.cloud_upload,
|
||||
iconColor: const Color(0xFF1A1A1A),
|
||||
title: '仅上传',
|
||||
subtitle: '将本地数据上传到云端,覆盖云端数据',
|
||||
onTap: () {
|
||||
title: '上传',
|
||||
subtitle: '将本地数据备份到云端',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_navigateToSync(context, SyncMode.uploadOnly);
|
||||
// 等待对话框关闭
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
_navigateToSync(context, SyncMode.uploadOnly);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
|
||||
@@ -288,11 +280,15 @@ class MainContentPage extends StatelessWidget {
|
||||
context,
|
||||
icon: Icons.cloud_download,
|
||||
iconColor: const Color(0xFF1A1A1A),
|
||||
title: '仅下载',
|
||||
subtitle: '从云端下载数据到本地,覆盖本地数据',
|
||||
onTap: () {
|
||||
title: '下载',
|
||||
subtitle: '从云端恢复数据到本地',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_navigateToSync(context, SyncMode.downloadOnly);
|
||||
// 等待对话框关闭
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
_navigateToSync(context, SyncMode.downloadOnly);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -395,30 +391,35 @@ class MainContentPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 导航到同步页面
|
||||
void _navigateToSync(BuildContext context, SyncMode mode) {
|
||||
// TODO: 打开 WebDAV 同步页面并传递同步模式
|
||||
// Navigator.push(
|
||||
// context,
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => WebDAVSyncPage(syncMode: mode),
|
||||
// ),
|
||||
// );
|
||||
/// 执行云备份操作
|
||||
Future<void> _navigateToSync(BuildContext context, SyncMode mode) async {
|
||||
// 执行同步
|
||||
final direction = mode == SyncMode.uploadOnly
|
||||
? SyncDirection.upload
|
||||
: SyncDirection.download;
|
||||
|
||||
// 暂时显示提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('即将开始${_getSyncModeText(mode)}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
final result = await WebDAVService.instance.syncData(direction: direction);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (result.success) {
|
||||
// 如果需要重新加载数据(下载模式)
|
||||
if (result.needReload) {
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.loadMovies();
|
||||
await provider.loadBooks();
|
||||
await provider.loadNotes();
|
||||
}
|
||||
|
||||
ToastUtil.show(context, '${_getSyncModeText(mode)}成功');
|
||||
} else {
|
||||
ToastUtil.show(context, '${_getSyncModeText(mode)}失败: ${result.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取同步模式文本
|
||||
String _getSyncModeText(SyncMode mode) {
|
||||
switch (mode) {
|
||||
case SyncMode.bidirectional:
|
||||
return '双向同步';
|
||||
case SyncMode.uploadOnly:
|
||||
return '上传';
|
||||
case SyncMode.downloadOnly:
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
|
||||
/// 添加/编辑影视页面 - 紧凑双行布局设计
|
||||
class MovieFormPage extends StatefulWidget {
|
||||
@@ -691,18 +692,22 @@ class _MovieFormPageState extends State<MovieFormPage> {
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final savedPath = path.join(appDir.path, 'movie_posters', fileName);
|
||||
// 生成文件名
|
||||
final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
final posterDir = Directory(path.join(appDir.path, 'movie_posters'));
|
||||
if (!await posterDir.exists()) {
|
||||
await posterDir.create(recursive: true);
|
||||
}
|
||||
// 如果是编辑模式,使用现有影视ID;如果是新建模式,使用临时ID(保存时会替换)
|
||||
final movieId = widget.movie?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
await File(pickedFile.path).copy(savedPath);
|
||||
// 保存到新的路径结构: images/movies/{movieId}/{fileName}
|
||||
final targetPath = await ImagePathHelper.instance.getMoviePosterPath(
|
||||
movieId,
|
||||
fileName
|
||||
);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
|
||||
setState(() => _posterPath = savedPath);
|
||||
await File(pickedFile.path).copy(targetPath);
|
||||
|
||||
setState(() => _posterPath = targetPath);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -748,10 +753,19 @@ class _MovieFormPageState extends State<MovieFormPage> {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (widget.movie == null) {
|
||||
// 生成新的影视ID
|
||||
final newMovieId = now.millisecondsSinceEpoch.toString();
|
||||
|
||||
// 如果有海报,需要移动到正确的ID目录
|
||||
String? finalPosterPath;
|
||||
if (_posterPath != null && _posterPath!.isNotEmpty) {
|
||||
finalPosterPath = await _movePosterToNewId(_posterPath!, newMovieId);
|
||||
}
|
||||
|
||||
final newMovie = Movie(
|
||||
id: now.millisecondsSinceEpoch.toString(),
|
||||
id: newMovieId,
|
||||
title: _titleController.text.trim(),
|
||||
posterPath: _posterPath,
|
||||
posterPath: finalPosterPath,
|
||||
releaseDate: _releaseDate,
|
||||
directors: _directors,
|
||||
writers: _writers,
|
||||
@@ -791,4 +805,45 @@ class _MovieFormPageState extends State<MovieFormPage> {
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// 将海报从临时ID目录移动到新的影视ID目录
|
||||
Future<String?> _movePosterToNewId(String currentPath, String newMovieId) async {
|
||||
// 检查是否已经在正确的目录中(兼容 Windows 路径分隔符)
|
||||
final normalizedPath = currentPath.replaceAll('\\', '/');
|
||||
if (normalizedPath.contains('/movies/$newMovieId/')) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// 提取文件名
|
||||
final fileName = p.basename(currentPath);
|
||||
|
||||
// 获取新路径
|
||||
final newPath = await ImagePathHelper.instance.getMoviePosterPath(
|
||||
newMovieId,
|
||||
fileName
|
||||
);
|
||||
|
||||
// 确保目标目录存在
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath));
|
||||
|
||||
// 移动文件
|
||||
final currentFile = File(currentPath);
|
||||
if (await currentFile.exists()) {
|
||||
await currentFile.rename(newPath);
|
||||
|
||||
// 删除空的临时目录
|
||||
final tempDir = Directory(p.dirname(currentPath));
|
||||
if (await tempDir.exists()) {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (e) {
|
||||
// 忽略删除目录失败的情况
|
||||
}
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
import 'poster_gallery_page.dart';
|
||||
|
||||
/// 影视海报墙页面
|
||||
@@ -197,21 +198,22 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final savedPath = path.join(appDir.path, 'movie_posters', fileName);
|
||||
// 生成文件名
|
||||
final fileName = 'posterimg_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
// 保存到 posterimgs 子目录: images/movies/{movieId}/posterimgs/{fileName}
|
||||
final targetPath = await ImagePathHelper.instance.getMoviePosterImgPath(
|
||||
widget.movie.id,
|
||||
fileName
|
||||
);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
|
||||
final posterDir = Directory(path.join(appDir.path, 'movie_posters'));
|
||||
if (!await posterDir.exists()) {
|
||||
await posterDir.create(recursive: true);
|
||||
}
|
||||
|
||||
await File(pickedFile.path).copy(savedPath);
|
||||
await File(pickedFile.path).copy(targetPath);
|
||||
|
||||
final newPoster = MoviePoster(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
movieId: widget.movie.id,
|
||||
posterPath: savedPath,
|
||||
posterPath: targetPath,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
|
||||
@@ -101,108 +101,111 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
|
||||
}
|
||||
|
||||
Widget _buildReviewItem(MovieReview review) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部:类型标签 + 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFFF5F5F5)
|
||||
: const Color(0xFF1A1A1A),
|
||||
),
|
||||
child: Text(
|
||||
review.typeText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
return InkWell(
|
||||
onLongPress: () => _showDeleteDialog(review),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部:类型标签 + 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFF666666)
|
||||
: Colors.white,
|
||||
? const Color(0xFFF5F5F5)
|
||||
: const Color(0xFF1A1A1A),
|
||||
),
|
||||
child: Text(
|
||||
review.typeText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: review.reviewType == 1
|
||||
? const Color(0xFF666666)
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 编辑按钮
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToEditReview(review),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 删除按钮
|
||||
GestureDetector(
|
||||
onTap: () => _showDeleteDialog(review),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 评论内容
|
||||
Text(
|
||||
review.content,
|
||||
maxLines: review.reviewType == 1 ? 3 : 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 底部信息
|
||||
Row(
|
||||
children: [
|
||||
if (review.reviewer.isNotEmpty) ...[
|
||||
Text(
|
||||
review.reviewer,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
const Spacer(),
|
||||
// 编辑按钮
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToEditReview(review),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 删除按钮
|
||||
GestureDetector(
|
||||
onTap: () => _showDeleteDialog(review),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (review.source.isNotEmpty) ...[
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 评论内容
|
||||
Text(
|
||||
review.content,
|
||||
maxLines: review.reviewType == 1 ? 3 : 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 底部信息
|
||||
Row(
|
||||
children: [
|
||||
if (review.reviewer.isNotEmpty) ...[
|
||||
Text(
|
||||
review.reviewer,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (review.source.isNotEmpty) ...[
|
||||
Text(
|
||||
'来源:${review.source}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
'来源:${review.source}',
|
||||
_formatDate(review.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDate(review.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -259,4 +262,4 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -18,33 +19,50 @@ class NoteDetailPage extends StatefulWidget {
|
||||
class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 从 Provider 获取最新的笔记数据
|
||||
final note = context.watch<AppProvider>().notes.firstWhere(
|
||||
(n) => n.id == widget.note.id,
|
||||
orElse: () => widget.note,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: Text(_formatDateTime(widget.note.createdAt)),
|
||||
title: Text(_getTitle(note.content)),
|
||||
actions: [
|
||||
// 格式指示器
|
||||
if (widget.note.contentType == 'markdown')
|
||||
// 格式指示器 - 纯文本标记
|
||||
if (note.contentType == 'markdown')
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.code, size: 14, color: Color(0xFF666666)),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Markdown',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Text(
|
||||
'MD',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: const Text(
|
||||
'TXT',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -57,9 +75,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
body: Column(
|
||||
children: [
|
||||
// 标签区域
|
||||
if (widget.note.tags.isNotEmpty)
|
||||
if (note.tags.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
|
||||
@@ -70,7 +88,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.note.tags.map((tag) {
|
||||
children: note.tags.map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -93,11 +111,41 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: widget.note.contentType == 'markdown'
|
||||
? _buildMarkdownContent()
|
||||
: _buildPlainTextContent(),
|
||||
child: note.contentType == 'markdown'
|
||||
? _buildMarkdownContent(note)
|
||||
: _buildPlainTextContent(note),
|
||||
),
|
||||
|
||||
// 图片区域(仅在纯文本模式下显示)
|
||||
if (note.contentType == 'plain_text' && note.images.isNotEmpty)
|
||||
Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: note.images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Image.file(
|
||||
File(note.images[index]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
@@ -107,33 +155,53 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'更新于 ${_formatDateTime(widget.note.updatedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
// 创建时间和更新时间
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
color: const Color(0xFF666666),
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'创建时间:${_formatDateTime(note.createdAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'更新时间:${_formatDateTime(note.updatedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: Colors.red,
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
color: const Color(0xFF666666),
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: Colors.red,
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -148,9 +216,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
}
|
||||
|
||||
/// 构建 Markdown 内容
|
||||
Widget _buildMarkdownContent() {
|
||||
Widget _buildMarkdownContent(Note note) {
|
||||
return Markdown(
|
||||
data: widget.note.content,
|
||||
data: note.content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
h1: const TextStyle(
|
||||
fontSize: 24,
|
||||
@@ -204,20 +272,24 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.all(16),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建纯文本内容
|
||||
Widget _buildPlainTextContent() {
|
||||
Widget _buildPlainTextContent(Note note) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
widget.note.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.8,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
note.content,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -228,6 +300,17 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 获取标题(内容前5个字)
|
||||
String _getTitle(String content) {
|
||||
if (content.isEmpty) return '无标题';
|
||||
// 移除换行符和多余空格
|
||||
final trimmed = content.replaceAll('\n', ' ').trim();
|
||||
if (trimmed.isEmpty) return '无标题';
|
||||
// 取前5个字
|
||||
if (trimmed.length <= 5) return trimmed;
|
||||
return '${trimmed.substring(0, 5)}...';
|
||||
}
|
||||
|
||||
/// 跳转到编辑页面
|
||||
void _navigateToEdit(BuildContext context) {
|
||||
Navigator.pushNamed(context, '/note-form', arguments: widget.note).then((_) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
|
||||
/// 添加/编辑笔记页面 - 极简书写界面
|
||||
class NoteFormPage extends StatefulWidget {
|
||||
@@ -18,8 +22,11 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
late TextEditingController _contentController;
|
||||
late DateTime _createdAt;
|
||||
List<String> _tags = [];
|
||||
String _contentType = 'markdown'; // markdown / rich_text
|
||||
List<String> _images = []; // 图片路径列表
|
||||
String _contentType = 'markdown'; // markdown / plain_text
|
||||
bool _isEditing = false;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
String? _tempNoteId; // 新建模式时使用的临时笔记ID
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -28,6 +35,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
_contentController = TextEditingController(text: note?.content ?? '');
|
||||
_createdAt = note?.createdAt ?? DateTime.now();
|
||||
_tags = note != null ? List.from(note.tags) : [];
|
||||
_images = note != null ? List.from(note.images) : [];
|
||||
_contentType = note?.contentType ?? 'markdown';
|
||||
_isEditing = note != null;
|
||||
}
|
||||
@@ -93,29 +101,159 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// 书写区域
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: _contentType == 'markdown' ? '使用 Markdown 格式书写...' : '开始书写...',
|
||||
hintStyle: const TextStyle(
|
||||
// 书写区域(纯文本模式下占据35%高度,Markdown模式下占据全部)
|
||||
if (_contentType == 'plain_text')
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.35,
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFFCCCCCC),
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '开始书写...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.6,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '使用 Markdown 格式书写...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 纯文本模式下的图片区域
|
||||
if (_contentType == 'plain_text') ...[
|
||||
// 图片网格区域
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'图片',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_images.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 添加图片按钮
|
||||
InkWell(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'添加',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 图片网格(4列,正方形铺满)
|
||||
Expanded(
|
||||
child: _images.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: const Color(0xFFCCCCCC),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'点击添加按钮添加图片',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: _images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildImageItem(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -140,7 +278,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_contentType == 'markdown' ? 'Markdown' : '富文本',
|
||||
_contentType == 'markdown' ? 'Markdown' : '纯文本',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF666666),
|
||||
@@ -184,12 +322,12 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.text_fields, size: 20),
|
||||
title: const Text('纯文本'),
|
||||
subtitle: const Text('普通文本格式'),
|
||||
trailing: _contentType == 'rich_text'
|
||||
subtitle: const Text('普通文本格式,支持图片'),
|
||||
trailing: _contentType == 'plain_text'
|
||||
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() => _contentType = 'rich_text');
|
||||
setState(() => _contentType = 'plain_text');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -344,16 +482,29 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
content: content,
|
||||
contentType: _contentType,
|
||||
tags: _tags,
|
||||
images: _images,
|
||||
updatedAt: now,
|
||||
);
|
||||
await context.read<AppProvider>().updateNote(updatedNote);
|
||||
} else {
|
||||
// 添加新笔记
|
||||
// 添加新笔记 - 先创建笔记获取ID
|
||||
final noteId = now.millisecondsSinceEpoch.toString();
|
||||
|
||||
// 如果有图片,需要移动到正确的ID目录
|
||||
List<String> finalImages = [];
|
||||
if (_images.isNotEmpty) {
|
||||
// 使用保存的临时ID,如果没有则使用当前noteId(理论上不会走到这里)
|
||||
final oldNoteId = _tempNoteId ?? noteId;
|
||||
final newNoteId = noteId;
|
||||
finalImages = await _moveImagesToNewId(oldNoteId, newNoteId);
|
||||
}
|
||||
|
||||
final newNote = Note(
|
||||
id: now.millisecondsSinceEpoch.toString(),
|
||||
id: noteId,
|
||||
content: content,
|
||||
contentType: _contentType,
|
||||
tags: _tags,
|
||||
images: finalImages.isNotEmpty ? finalImages : _images,
|
||||
createdAt: _createdAt,
|
||||
updatedAt: now,
|
||||
);
|
||||
@@ -366,4 +517,123 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// 将图片从临时ID目录移动到新ID目录
|
||||
Future<List<String>> _moveImagesToNewId(String oldNoteId, String newNoteId) async {
|
||||
final List<String> newPaths = [];
|
||||
|
||||
final newDir = await ImagePathHelper.instance.getNoteImagesDir(newNoteId);
|
||||
|
||||
for (final imagePath in _images) {
|
||||
// 使用路径分隔符检查,兼容 Windows 和 Unix
|
||||
final normalizedPath = imagePath.replaceAll('\\', '/');
|
||||
if (normalizedPath.contains('/notes/$oldNoteId/')) {
|
||||
// 需要移动的文件
|
||||
final fileName = p.basename(imagePath);
|
||||
final newPath = p.join(newDir, fileName);
|
||||
|
||||
await ImagePathHelper.instance.ensureDirExists(newDir);
|
||||
|
||||
// 检查源文件是否存在
|
||||
final sourceFile = File(imagePath);
|
||||
if (await sourceFile.exists()) {
|
||||
await sourceFile.rename(newPath);
|
||||
newPaths.add(newPath);
|
||||
}
|
||||
} else {
|
||||
// 已经在正确位置的文件
|
||||
newPaths.add(imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧目录
|
||||
try {
|
||||
await ImagePathHelper.instance.deleteNoteImages(oldNoteId);
|
||||
} catch (e) {
|
||||
// 忽略删除失败
|
||||
}
|
||||
|
||||
return newPaths;
|
||||
}
|
||||
|
||||
/// 选择图片
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
// 生成唯一的文件名
|
||||
final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
// 如果是编辑模式,使用现有笔记ID;如果是新建模式,使用临时ID(保存时会替换)
|
||||
String noteId;
|
||||
if (_isEditing) {
|
||||
noteId = widget.note!.id;
|
||||
} else {
|
||||
// 新建模式:使用已存在的临时ID或生成新的
|
||||
noteId = _tempNoteId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
_tempNoteId = noteId;
|
||||
}
|
||||
|
||||
// 复制图片到应用目录: images/notes/{noteId}/{fileName}
|
||||
final targetDir = await ImagePathHelper.instance.getNoteImagesDir(noteId);
|
||||
await ImagePathHelper.instance.ensureDirExists(targetDir);
|
||||
final targetPath = p.join(targetDir, fileName);
|
||||
|
||||
await File(image.path).copy(targetPath);
|
||||
|
||||
setState(() => _images.add(targetPath));
|
||||
}
|
||||
} catch (e) {
|
||||
ToastUtil.show(context, '选择图片失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建图片项
|
||||
Widget _buildImageItem(int index) {
|
||||
return InkWell(
|
||||
onLongPress: () => _showDeleteImageDialog(index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Image.file(
|
||||
File(_images[index]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除图片确认对话框
|
||||
void _showDeleteImageDialog(int index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('确定要删除这张图片吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _images.removeAt(index));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('删除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ class NoteTabPage extends StatelessWidget {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadNotes(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: notes.length,
|
||||
@@ -50,25 +52,45 @@ class NoteTabPage extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.note_outlined,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无笔记',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.note_outlined,
|
||||
size: 40,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('添加笔记'),
|
||||
onPressed: () {
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'暂无笔记',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/note-form');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
),
|
||||
child: const Text(
|
||||
'添加笔记',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -396,7 +396,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
|
||||
_buildMenuItem(
|
||||
icon: Icons.backup_outlined,
|
||||
title: '数据备份',
|
||||
title: '本地备份',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -408,7 +408,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
|
||||
_buildMenuItem(
|
||||
icon: Icons.cloud_sync_outlined,
|
||||
title: '云同步',
|
||||
title: '云备份',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../utils/toast_util.dart';
|
||||
import '../utils/webdav_service.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
|
||||
/// WebDAV 同步页面
|
||||
/// WebDAV 备份页面
|
||||
class WebDAVSyncPage extends StatefulWidget {
|
||||
const WebDAVSyncPage({super.key});
|
||||
|
||||
@@ -21,13 +21,14 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
bool _isLoading = false;
|
||||
bool _isConfigured = false;
|
||||
bool _obscurePassword = true;
|
||||
SyncDirection _syncDirection = SyncDirection.bidirectional;
|
||||
SyncResult? _lastSyncResult;
|
||||
bool _isAutoSyncEnabled = false;
|
||||
SyncDirection _syncDirection = SyncDirection.upload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConfig();
|
||||
_loadAutoSyncStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -39,6 +40,33 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载自动同步状态
|
||||
Future<void> _loadAutoSyncStatus() async {
|
||||
final enabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||
setState(() => _isAutoSyncEnabled = enabled);
|
||||
}
|
||||
|
||||
/// 切换自动同步
|
||||
Future<void> _toggleAutoSync(bool value) async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
await WebDAVService.instance.startAutoSync();
|
||||
ToastUtil.show(context, '自动备份已开启,每2分钟执行一次');
|
||||
} else {
|
||||
await WebDAVService.instance.stopAutoSync();
|
||||
ToastUtil.show(context, '自动备份已关闭');
|
||||
}
|
||||
|
||||
setState(() => _isAutoSyncEnabled = value);
|
||||
} catch (e) {
|
||||
ToastUtil.show(context, '操作失败: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载已保存的配置
|
||||
Future<void> _loadConfig() async {
|
||||
final config = await WebDAVService.instance.getConfig();
|
||||
@@ -98,13 +126,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
setState(() => _isConfigured = true);
|
||||
ToastUtil.show(context, result['message'] ?? '连接成功,配置已保存');
|
||||
|
||||
// 延迟一下再返回,确保 Toast 显示出来
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// 如果是首次配置成功,返回 true 给调用方
|
||||
if (mounted) {
|
||||
Navigator.maybePop(context, true);
|
||||
}
|
||||
// 连接成功后停留在当前页面,不返回上级
|
||||
} else {
|
||||
ToastUtil.show(context, result['message'] ?? '连接失败,请检查配置');
|
||||
}
|
||||
@@ -128,8 +150,6 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _lastSyncResult = result);
|
||||
|
||||
if (result.success) {
|
||||
final details = '上传: ${result.uploadedFiles} 文件, ${result.uploadedImages} 图片\n'
|
||||
'下载: ${result.downloadedFiles} 文件, ${result.downloadedImages} 图片';
|
||||
@@ -228,7 +248,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('WebDAV 同步'),
|
||||
title: const Text('WebDAV 备份'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
@@ -337,6 +357,56 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
if (_isConfigured) ...[
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 自动备份开关
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 20,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'自动备份',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'每2分钟自动备份一次,保留最近10个备份',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _isAutoSyncEnabled,
|
||||
onChanged: _isLoading ? null : _toggleAutoSync,
|
||||
activeColor: const Color(0xFF1A1A1A),
|
||||
inactiveThumbColor: const Color(0xFF999999),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 同步方向选择
|
||||
const Text(
|
||||
'同步方向',
|
||||
@@ -351,15 +421,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDirectionButton(
|
||||
'双向同步',
|
||||
SyncDirection.bidirectional,
|
||||
Icons.sync,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildDirectionButton(
|
||||
'仅上传',
|
||||
'上传',
|
||||
SyncDirection.upload,
|
||||
Icons.upload,
|
||||
),
|
||||
@@ -367,7 +429,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildDirectionButton(
|
||||
'仅下载',
|
||||
'下载',
|
||||
SyncDirection.download,
|
||||
Icons.download,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user