From 6c3e55fead89952aa1f52d8beaaa2adbaaec347d Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Fri, 22 May 2026 18:23:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 9 + lib/models/data_models_extension.dart | 108 -- .../markdown_reader/md_reader_tab_page.dart | 32 +- lib/pages/movies/movie_form_page_new.dart | 554 --------- lib/pages/note/note_form_page.dart | 15 +- lib/pages/profile_page.dart | 114 +- lib/pages/stroll_page.dart | 40 + lib/pages/sync/backup_page.dart | 391 +++--- lib/pages/sync/cloud_sync_page.dart | 33 +- lib/pages/sync/webdav_sync_page.dart | 1060 ++++++----------- lib/pages/tag_management_page.dart | 659 ++++++++++ lib/providers/app_provider.dart | 39 + lib/utils/data_migration.dart | 372 ------ lib/utils/database_helper.dart | 79 +- lib/utils/sync/webdav_service.dart | 548 +++------ lib/utils/tag/tag_dao.dart | 226 ++++ lib/widgets/custom_drawer.dart | 47 +- lib/widgets/markdown_editing_controller.dart | 373 ------ 18 files changed, 2004 insertions(+), 2695 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 lib/models/data_models_extension.dart delete mode 100644 lib/pages/movies/movie_form_page_new.dart create mode 100644 lib/pages/tag_management_page.dart delete mode 100644 lib/utils/data_migration.dart create mode 100644 lib/utils/tag/tag_dao.dart delete mode 100644 lib/widgets/markdown_editing_controller.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2dd9558 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter analyze *)", + "Bash(python _fix_script.py)", + "Bash(dart analyze *)" + ] + } +} diff --git a/lib/models/data_models_extension.dart b/lib/models/data_models_extension.dart deleted file mode 100644 index bd1795c..0000000 --- a/lib/models/data_models_extension.dart +++ /dev/null @@ -1,108 +0,0 @@ -/// 数据模型扩展 - 添加 copyWith 方法以便更新数据 -library; - -import 'data_models.dart'; - -/// Movie 扩展 - 添加 copyWith 方法 -extension MovieExtension on Movie { - /// 创建副本并允许修改部分属性 - Movie copyWith({ - String? id, - String? title, - String? posterPath, - DateTime? releaseDate, - List? directors, - List? writers, - List? actors, - List? genres, - List? alternateTitles, - String? summary, - double? rating, - String? status, - DateTime? createdAt, - DateTime? updatedAt, - bool? isDeleted, - }) { - return Movie( - id: id ?? this.id, - title: title ?? this.title, - posterPath: posterPath ?? this.posterPath, - releaseDate: releaseDate ?? this.releaseDate, - directors: directors ?? this.directors, - writers: writers ?? this.writers, - actors: actors ?? this.actors, - genres: genres ?? this.genres, - alternateTitles: alternateTitles ?? this.alternateTitles, - summary: summary ?? this.summary, - rating: rating ?? this.rating, - status: status ?? this.status, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - isDeleted: isDeleted ?? this.isDeleted, - ); - } -} - -/// Book 扩展 - 添加 copyWith 方法 -extension BookExtension on Book { - /// 创建副本并允许修改部分属性 - Book copyWith({ - String? id, - String? title, - String? coverPath, - List? authors, - List? alternateTitles, - String? publisher, - List? genres, - String? summary, - double? rating, - String? status, - DateTime? createdAt, - DateTime? updatedAt, - bool? isDeleted, - }) { - return Book( - id: id ?? this.id, - title: title ?? this.title, - coverPath: coverPath ?? this.coverPath, - authors: authors ?? this.authors, - alternateTitles: alternateTitles ?? this.alternateTitles, - publisher: publisher ?? this.publisher, - genres: genres ?? this.genres, - summary: summary ?? this.summary, - rating: rating ?? this.rating, - status: status ?? this.status, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - isDeleted: isDeleted ?? this.isDeleted, - ); - } -} - -/// Note 扩展 - 添加 copyWith 方法 -extension NoteExtension on Note { - /// 创建副本并允许修改部分属性 - Note copyWith({ - String? id, - String? title, - String? content, - String? contentType, - List? tags, - List? images, - DateTime? createdAt, - DateTime? updatedAt, - bool? isDeleted, - }) { - return Note( - id: id ?? this.id, - title: title ?? this.title, - content: content ?? this.content, - contentType: contentType ?? this.contentType, - tags: tags ?? this.tags, - images: images ?? this.images, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - isDeleted: isDeleted ?? this.isDeleted, - ); - } -} diff --git a/lib/pages/markdown_reader/md_reader_tab_page.dart b/lib/pages/markdown_reader/md_reader_tab_page.dart index 91f967a..6f93e88 100644 --- a/lib/pages/markdown_reader/md_reader_tab_page.dart +++ b/lib/pages/markdown_reader/md_reader_tab_page.dart @@ -322,22 +322,22 @@ class _MdReaderTabPageState extends State { onPressed: () => Navigator.pop(context), ), actions: [ - if (_canGoBack) - Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: _goBack, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: const Text('返回上级', style: TextStyle(fontSize: 12, color: Color(0xFF666666))), - ), - ), - ), + // if (_canGoBack) + // Padding( + // padding: const EdgeInsets.only(right: 8), + // child: GestureDetector( + // onTap: _goBack, + // child: Container( + // padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + // decoration: BoxDecoration( + // color: const Color(0xFFF5F5F5), + // borderRadius: BorderRadius.circular(14), + // border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + // ), + // child: const Text('返回上级', style: TextStyle(fontSize: 12, color: Color(0xFF666666))), + // ), + // ), + // ), if (_currentPath != null) Padding( padding: const EdgeInsets.only(right: 4), diff --git a/lib/pages/movies/movie_form_page_new.dart b/lib/pages/movies/movie_form_page_new.dart deleted file mode 100644 index 70ca906..0000000 --- a/lib/pages/movies/movie_form_page_new.dart +++ /dev/null @@ -1,554 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:provider/provider.dart'; -import 'package:uuid/uuid.dart'; -import '../../providers/app_provider.dart'; -import '../../models/data_models.dart'; -import '../../utils/toast_util.dart'; - -/// 添加/编辑影视记录页面(Typecho 风格) -class MovieFormPage extends StatefulWidget { - final Movie? movie; - - const MovieFormPage({super.key, this.movie}); - - @override - State createState() => _MovieFormPageState(); -} - -class _MovieFormPageState extends State { - final _formKey = GlobalKey(); - late TextEditingController _titleController; - late TextEditingController _releaseDateController; - late TextEditingController _directorsController; - late TextEditingController _writersController; - late TextEditingController _actorsController; - late TextEditingController _genresController; - late TextEditingController _alternateTitlesController; - late TextEditingController _summaryController; - late TextEditingController _ratingController; - - String _status = 'want_to_watch'; - File? _posterImage; - bool _isLoading = false; - - final ImagePicker _picker = ImagePicker(); - - @override - void initState() { - super.initState(); - _titleController = TextEditingController(text: widget.movie?.title ?? ''); - _releaseDateController = TextEditingController( - text: widget.movie?.releaseDate != null - ? _formatDate(widget.movie!.releaseDate!) - : '', - ); - _directorsController = TextEditingController( - text: (widget.movie?.directors ?? []).join(', '), - ); - _writersController = TextEditingController( - text: (widget.movie?.writers ?? []).join(', '), - ); - _actorsController = TextEditingController( - text: (widget.movie?.actors ?? []).join(', '), - ); - _genresController = TextEditingController( - text: (widget.movie?.genres ?? []).join(', '), - ); - _alternateTitlesController = TextEditingController( - text: (widget.movie?.alternateTitles ?? []).join(', '), - ); - _summaryController = TextEditingController(text: widget.movie?.summary ?? ''); - _ratingController = TextEditingController( - text: widget.movie?.rating?.toString() ?? '', - ); - _status = widget.movie?.status ?? 'want_to_watch'; - - if (widget.movie?.posterPath != null) { - _posterImage = File(widget.movie!.posterPath!); - } - } - - @override - void dispose() { - _titleController.dispose(); - _releaseDateController.dispose(); - _directorsController.dispose(); - _writersController.dispose(); - _actorsController.dispose(); - _genresController.dispose(); - _alternateTitlesController.dispose(); - _summaryController.dispose(); - _ratingController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isEdit = widget.movie != null; - - return Scaffold( - appBar: AppBar( - title: Text(isEdit ? '编辑影视' : '添加影视'), - actions: [ - IconButton( - icon: const Icon(Icons.save), - onPressed: _isLoading ? null : _saveMovie, - ), - ], - ), - body: SingleChildScrollView( - child: Column( - children: [ - // 封面上传区域 - _buildCoverSection(), - - const SizedBox(height: 24), - - // 表单区域 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBasicInfoSection(), - const SizedBox(height: 32), - _buildCastSection(), - const SizedBox(height: 32), - _buildDetailSection(), - const SizedBox(height: 48), - _buildSaveButton(isEdit), - const SizedBox(height: 32), - ], - ), - ), - ), - ], - ), - ), - ); - } - - /// 构建封面上传区域 - Widget _buildCoverSection() { - return GestureDetector( - onTap: _pickImage, - child: Container( - width: double.infinity, - height: 300, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: _posterImage != null - ? Stack( - fit: StackFit.expand, - children: [ - Image.file( - _posterImage!, - fit: BoxFit.cover, - ), - Positioned( - top: 16, - right: 16, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - Icons.camera_alt, - color: Colors.white, - ), - ), - ), - ], - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_photo_alternate_outlined, - size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - '点击上传海报', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - '建议尺寸:2:3 比例', - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6), - ), - ), - ], - ), - ), - ); - } - - /// 构建基本信息区域 - Widget _buildBasicInfoSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionTitle('基本信息'), - const SizedBox(height: 16), - - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: '影视名称 *', - hintText: '请输入影视名称', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.title), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入影视名称'; - } - return null; - }, - ), - - const SizedBox(height: 16), - - Row( - children: [ - Expanded( - child: TextFormField( - controller: _releaseDateController, - decoration: const InputDecoration( - labelText: '上映时间', - hintText: 'YYYY-MM-DD', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.calendar_today), - ), - readOnly: true, - onTap: _selectReleaseDate, - ), - ), - - const SizedBox(width: 16), - - Expanded( - child: TextFormField( - controller: _ratingController, - decoration: const InputDecoration( - labelText: '评分', - hintText: '1-10', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.star), - ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - validator: (value) { - if (value != null && value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating == null || rating < 1 || rating > 10) { - return '1-10'; - } - } - return null; - }, - ), - ), - ], - ), - - const SizedBox(height: 16), - - DropdownButtonFormField( - value: _status, - decoration: const InputDecoration( - labelText: '状态', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.check_circle_outline), - ), - items: const [ - DropdownMenuItem(value: 'watched', child: Text('已看')), - DropdownMenuItem(value: 'want_to_watch', child: Text('想看')), - DropdownMenuItem(value: 'watching', child: Text('在看')), - ], - onChanged: (value) { - setState(() { - _status = value!; - }); - }, - ), - ], - ); - } - - /// 构建演职人员区域 - Widget _buildCastSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionTitle('演职人员'), - const SizedBox(height: 16), - - TextFormField( - controller: _directorsController, - decoration: const InputDecoration( - labelText: '导演', - hintText: '多个用逗号分隔', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person), - ), - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _writersController, - decoration: const InputDecoration( - labelText: '编剧', - hintText: '多个用逗号分隔', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.edit_note), - ), - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _actorsController, - decoration: const InputDecoration( - labelText: '主演', - hintText: '多个用逗号分隔', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.people), - ), - ), - ], - ); - } - - /// 构建详细信息区域 - Widget _buildDetailSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionTitle('详细信息'), - const SizedBox(height: 16), - - TextFormField( - controller: _genresController, - decoration: const InputDecoration( - labelText: '类型', - hintText: '多个用逗号分隔', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _alternateTitlesController, - decoration: const InputDecoration( - labelText: '别名', - hintText: '多个用逗号分隔', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.alt_route), - ), - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _summaryController, - decoration: const InputDecoration( - labelText: '剧情简介', - hintText: '请输入剧情简介...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - prefixIcon: Icon(Icons.description), - ), - maxLines: 6, - ), - ], - ); - } - - /// 构建保存按钮 - Widget _buildSaveButton(bool isEdit) { - return SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton.icon( - onPressed: _isLoading ? null : _saveMovie, - icon: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: Text(isEdit ? '保存修改' : '添加记录'), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ); - } - - /// 构建区块标题 - Widget _buildSectionTitle(String title) { - return Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ); - } - - /// 选择图片 - Future _pickImage() async { - final XFile? image = await _picker.pickImage(source: ImageSource.gallery); - - if (image != null) { - setState(() { - _posterImage = File(image.path); - }); - } - } - - /// 选择日期 - Future _selectReleaseDate() async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - - if (picked != null) { - setState(() { - _releaseDateController.text = _formatDate(picked); - }); - } - } - - /// 格式化日期 - String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - } - - /// 保存影视记录 - Future _saveMovie() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - _isLoading = true; - }); - - try { - // 处理列表字段 - final directors = _parseList(_directorsController.text); - final writers = _parseList(_writersController.text); - final actors = _parseList(_actorsController.text); - final genres = _parseList(_genresController.text); - final alternateTitles = _parseList(_alternateTitlesController.text); - - // 处理评分 - final rating = _ratingController.text.isNotEmpty - ? double.parse(_ratingController.text) - : null; - - // 处理日期 - final releaseDate = _releaseDateController.text.isNotEmpty - ? DateTime.tryParse(_releaseDateController.text) - : null; - - // 处理图片 - String? posterPath; - if (_posterImage != null) { - final dir = await getApplicationDocumentsDirectory(); - final fileName = '${const Uuid().v4()}.jpg'; - final savedImage = await _posterImage!.copy('${dir.path}/posters/$fileName'); - posterPath = savedImage.path; - } - - if (widget.movie == null) { - // 添加新模式 - final now = DateTime.now(); - final newMovie = Movie( - id: const Uuid().v4(), - title: _titleController.text.trim(), - posterPath: posterPath, - releaseDate: releaseDate, - directors: directors, - writers: writers, - actors: actors, - genres: genres, - alternateTitles: alternateTitles, - summary: _summaryController.text.trim(), - rating: rating, - status: _status, - createdAt: now, - updatedAt: now, - ); - - await context.read().addMovie(newMovie); - } else { - // 编辑现有模式 - final updatedMovie = Movie( - id: widget.movie!.id, - title: _titleController.text.trim(), - posterPath: posterPath ?? widget.movie!.posterPath, - releaseDate: releaseDate, - directors: directors, - writers: writers, - actors: actors, - genres: genres, - alternateTitles: alternateTitles, - summary: _summaryController.text.trim(), - rating: rating, - status: _status, - createdAt: widget.movie!.createdAt, - updatedAt: DateTime.now(), - ); - - await context.read().updateMovie(updatedMovie); - } - - if (!mounted) return; - - ToastUtil.show(context, widget.movie == null ? '添加成功' : '更新成功'); - - Navigator.pop(context); - } catch (e) { - if (!mounted) return; - ToastUtil.show(context, '保存失败:$e'); - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - } - } - - /// 解析列表 - List _parseList(String text) { - return text - .split(',') - .map((item) => item.trim()) - .where((item) => item.isNotEmpty) - .toList(); - } -} diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index bc40f48..39f5927 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -59,11 +59,9 @@ class _NoteFormPageState extends State { @override Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Scaffold( backgroundColor: Colors.white, - resizeToAvoidBottomInset: false, + resizeToAvoidBottomInset: true, appBar: AppBar( title: GestureDetector( onLongPress: _showTitleDialog, @@ -92,7 +90,7 @@ class _NoteFormPageState extends State { children: [ // 主内容区域 — 图片网格固定在内容下方 Padding( - padding: EdgeInsets.only(bottom: 56 + bottomInset), + padding: const EdgeInsets.only(bottom: 56), child: Column( children: [ // 顶部信息栏 @@ -176,7 +174,7 @@ class _NoteFormPageState extends State { Positioned( left: 0, right: 0, - bottom: bottomInset, + bottom: 0, child: _buildFloatingToolbar(), ), ], @@ -391,15 +389,16 @@ class _NoteFormPageState extends State { color: Color(0xFF1A1A1A), height: 1.6, ), - decoration: InputDecoration( + decoration: const InputDecoration( hintText: '使用 Markdown 格式书写...', - hintStyle: const TextStyle( + hintStyle: TextStyle( fontSize: 16, color: Color(0xFFCCCCCC), height: 1.6, ), border: InputBorder.none, - contentPadding: const EdgeInsets.all(16), + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.all(16), ), ); } diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 4ea8ce6..7276722 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -435,31 +435,11 @@ class _ProfilePageState extends State { _buildMenuItem( icon: Icons.backup_outlined, - title: '本地备份', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const BackupPage()), - ).then((_) { - // 返回时刷新用户数据 - _loadUserData(); - }); - }, + title: '备份', + onTap: () => _showBackupOptions(context), ), const Divider(height: 1, indent: 72, endIndent: 16, color: Color(0xFFE8E8E8)), - - _buildMenuItem( - icon: Icons.cloud_sync_outlined, - title: '云备份', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const CloudSyncPage()), - ); - }, - ), - const Divider(height: 1, indent: 72, endIndent: 16, color: Color(0xFFE8E8E8)), - + _buildMenuItem( icon: Icons.delete_outline, title: '回收站', @@ -527,6 +507,82 @@ class _ProfilePageState extends State { ); } + /// 显示备份选项 + void _showBackupOptions(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: const Color(0xFFDDDDDD), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Align( + alignment: Alignment.centerLeft, + child: Text('选择备份方式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.folder_outlined, color: Color(0xFF666666)), + ), + title: const Text('本地备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), + subtitle: const Text('备份到本地文件夹,支持恢复', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + trailing: const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC)), + onTap: () { + Navigator.pop(ctx); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BackupPage()), + ).then((_) => _loadUserData()); + }, + ), + const Divider(height: 0.5, color: Color(0xFFF0F0F0)), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.cloud_outlined, color: Color(0xFF666666)), + ), + title: const Text('云备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), + subtitle: const Text('通过 WebDAV 同步到云端', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + trailing: const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC)), + onTap: () { + Navigator.pop(ctx); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const CloudSyncPage()), + ); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } + /// 显示提示 void _showToast(String message) { ToastUtil.show(context, message); @@ -541,22 +597,22 @@ class _ProfilePageState extends State { maxHeight: 400, imageQuality: 85, ); - + if (pickedFile != null) { final appDir = await getApplicationDocumentsDirectory(); final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; final savedPath = path.join(appDir.path, 'avatars', fileName); - + final avatarDir = Directory(path.join(appDir.path, 'avatars')); if (!await avatarDir.exists()) { await avatarDir.create(recursive: true); } - + await File(pickedFile.path).copy(savedPath); - + // 保存到本地存储 await _userPrefs.setAvatarPath(savedPath); - + setState(() => _avatarPath = savedPath); } } catch (e) { @@ -565,8 +621,6 @@ class _ProfilePageState extends State { } } } - - /// 编辑昵称 void _editNickname(BuildContext context) { final controller = TextEditingController(text: _nickname); diff --git a/lib/pages/stroll_page.dart b/lib/pages/stroll_page.dart index 35a1e05..0179f76 100644 --- a/lib/pages/stroll_page.dart +++ b/lib/pages/stroll_page.dart @@ -58,6 +58,7 @@ class _StrollPageState extends State { imagePath: m.posterPath, icon: Icons.movie_outlined, label: '影视', + createdAt: m.createdAt, ); break; case 'book': @@ -70,6 +71,7 @@ class _StrollPageState extends State { imagePath: b.coverPath, icon: Icons.menu_book_outlined, label: '书籍', + createdAt: b.createdAt, ); break; case 'note': @@ -82,6 +84,7 @@ class _StrollPageState extends State { imagePath: n.images.isNotEmpty ? n.images.first : null, icon: Icons.note_outlined, label: '笔记', + createdAt: n.createdAt, ); break; default: @@ -110,6 +113,29 @@ class _StrollPageState extends State { return parts.join(' · '); } + String _getTimeAgoText(DateTime date) { + final diff = DateTime.now().difference(date); + if (diff.inDays >= 365) return '1年前'; + if (diff.inDays >= 180) return '6个月前'; + if (diff.inDays >= 90) return '3个月前'; + if (diff.inDays >= 30) return '1个月前'; + return '${diff.inDays}天前'; + } + + String _getActionTimeAgo(_StrollItem item) { + final timeAgo = _getTimeAgoText(item.createdAt); + switch (item.type) { + case 'movie': + return '${timeAgo}看过'; + case 'book': + return '${timeAgo}读过'; + case 'note': + return '${timeAgo}写下'; + default: + return timeAgo; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -226,6 +252,13 @@ class _StrollPageState extends State { ), ], + // 时间 + const SizedBox(height: 10), + Text( + _getActionTimeAgo(item), + style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC)), + ), + const SizedBox(height: 40), ], ), @@ -309,6 +342,11 @@ class _StrollPageState extends State { '${item.detail.length} 字', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), ), + const SizedBox(width: 10), + Text( + _getActionTimeAgo(item), + style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), + ), const Spacer(), const Text( 'Mooknote', @@ -350,6 +388,7 @@ class _StrollItem { final String? imagePath; final IconData icon; final String label; + final DateTime createdAt; _StrollItem({ required this.type, @@ -359,5 +398,6 @@ class _StrollItem { this.imagePath, required this.icon, required this.label, + required this.createdAt, }); } diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart index a3a4633..bf50e51 100644 --- a/lib/pages/sync/backup_page.dart +++ b/lib/pages/sync/backup_page.dart @@ -50,11 +50,14 @@ class _BackupPageState extends State { : ListView( padding: const EdgeInsets.all(24), children: [ - // 数据操作区域 + // 自动备份开关 - 紧凑一行 + _buildAutoBackupSection(), + + const SizedBox(height: 24), + + // 手动备份 _buildSectionTitle('手动备份'), - const SizedBox(height: 16), - - // 导出数据 + const SizedBox(height: 12), _buildActionCard( title: '导出数据', description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备', @@ -63,10 +66,7 @@ class _BackupPageState extends State { isLoading: _isExporting, onTap: _exportData, ), - - const SizedBox(height: 16), - - // 导入数据 + const SizedBox(height: 12), _buildActionCard( title: '导入数据', description: '从备份文件导入数据,将覆盖当前所有数据', @@ -76,14 +76,9 @@ class _BackupPageState extends State { onTap: _importData, isDestructive: true, ), - + const SizedBox(height: 32), - - // 自动备份开关 - _buildAutoBackupSection(), - - const SizedBox(height: 32), - + // 使用说明 _buildInfoSection(), ], @@ -144,8 +139,8 @@ class _BackupPageState extends State { color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all( - color: isDestructive - ? Colors.red.withOpacity(0.3) + color: isDestructive + ? Colors.red.withOpacity(0.3) : const Color(0xFFE8E8E8), width: 0.5, ), @@ -184,37 +179,34 @@ class _BackupPageState extends State { ], ), const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: isLoading ? null : onTap, - style: ElevatedButton.styleFrom( - backgroundColor: isDestructive - ? Colors.red - : const Color(0xFF1A1A1A), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 14), + GestureDetector( + onTap: isLoading ? null : onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isLoading ? const Color(0xFFCCCCCC) : const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), ), - child: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + child: Center( + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + buttonText, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + ), ), - ) - : Text( - buttonText, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), + ), ), ), ], @@ -311,6 +303,78 @@ class _BackupPageState extends State { ); } + /// 显示成功弹窗 + void _showSuccessDialog({ + required String title, + required String content, + String? detail, + }) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.check, color: Color(0xFF1A1A1A), size: 24), + ), + const SizedBox(height: 16), + Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Text( + content, + style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6), + textAlign: TextAlign.center, + ), + if (detail != null && detail.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + detail, + style: const TextStyle(fontSize: 11, color: Color(0xFF999999)), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + minimumSize: const Size(120, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确定', style: TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), + ), + ], + ), + ); + } + /// 导出数据 Future _exportData() async { setState(() => _isExporting = true); @@ -323,29 +387,10 @@ class _BackupPageState extends State { if (result.cancelled) { ToastUtil.show(context, '已取消导出'); } else if (result.success) { - // 显示导出成功信息 - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('导出成功'), - content: Text( - '备份文件已保存到:\n${result.filePath}\n\n' - '包含数据:\n' - '• 影视: ${result.movieCount}\n' - '• 书籍: ${result.bookCount}\n' - '• 笔记: ${result.noteCount}\n' - '• 图片: ${result.imageCount}', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('确定'), - ), - ], - ), + _showSuccessDialog( + title: '导出成功', + content: '备份文件已保存,包含:\n影视 ${result.movieCount} · 书籍 ${result.bookCount} · 笔记 ${result.noteCount} · 图片 ${result.imageCount}', + detail: result.filePath ?? '', ); } else { ToastUtil.show(context, result.errorMessage ?? '导出失败'); @@ -370,18 +415,47 @@ class _BackupPageState extends State { backgroundColor: Colors.white, elevation: 0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('确认导入'), - content: const Text( - '导入数据将覆盖当前所有数据,此操作不可恢复。\n\n是否继续?', + title: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), + ), + const SizedBox(width: 12), + const Text('确认导入', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], ), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: const Padding( + padding: EdgeInsets.only(top: 16), + child: Text( + '导入数据将覆盖当前所有数据,此操作不可恢复。', + style: TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6), + ), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666), fontSize: 14)), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: const Text('确认导入', style: TextStyle(color: Colors.red)), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确认导入', style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)), ), ], ), @@ -403,22 +477,12 @@ class _BackupPageState extends State { await context.read().loadMovies(); await context.read().loadBooks(); await context.read().loadNotes(); - - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('导入成功'), - content: Text('成功导入数据:\n${result.statsText}'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('确定'), - ), - ], - ), + + if (!mounted) return; + + _showSuccessDialog( + title: '导入成功', + content: result.statsText, ); } else { ToastUtil.show(context, result.errorMessage ?? '导入失败'); @@ -434,109 +498,68 @@ class _BackupPageState extends State { } } - /// 构建自动备份区域 + /// 构建自动备份区域 - 紧凑一行 Widget _buildAutoBackupSection() { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: const Icon( - Icons.schedule, - size: 22, - color: Color(0xFF666666), - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '自动本地备份', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - SizedBox(height: 4), - Text( - '每2分钟自动备份,保留最近5个备份', - style: TextStyle( - fontSize: 13, - color: Color(0xFF666666), - ), - ), - ], - ), - ), - Switch( - value: _autoBackupEnabled, - onChanged: (value) async { - setState(() => _autoBackupEnabled = value); - await AutoBackupService.instance.setEnabled(value); - if (value) { - ToastUtil.show(context, '自动备份已开启'); - } else { - ToastUtil.show(context, '自动备份已关闭'); - } - await _loadAutoBackupStatus(); - }, - activeColor: const Color(0xFF1A1A1A), - activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3), - inactiveThumbColor: Colors.white, - inactiveTrackColor: const Color(0xFFE5E5E5), - ), - ], - ), - if (_backupDirPath != null && _autoBackupEnabled) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Row( - children: [ - const Icon( - Icons.folder_outlined, - size: 16, - color: Color(0xFF999999), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _backupDirPath!, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF666666), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), - ], + child: const Icon(Icons.schedule, size: 20, color: Color(0xFF666666)), + ), + const SizedBox(width: 12), + Expanded( + child: _backupDirPath != null && _autoBackupEnabled + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '自动本地备份', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + ), + const SizedBox(height: 2), + Text( + _backupDirPath!, + style: const TextStyle(fontSize: 11, color: Color(0xFF999999)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ) + : const Text( + '自动本地备份', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + ), + ), + Switch( + value: _autoBackupEnabled, + onChanged: (value) async { + setState(() => _autoBackupEnabled = value); + await AutoBackupService.instance.setEnabled(value); + if (value) { + ToastUtil.show(context, '自动备份已开启'); + } else { + ToastUtil.show(context, '自动备份已关闭'); + } + await _loadAutoBackupStatus(); + }, + activeColor: const Color(0xFF1A1A1A), + activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3), + inactiveThumbColor: Colors.white, + inactiveTrackColor: const Color(0xFFE5E5E5), + ), ], ), ); diff --git a/lib/pages/sync/cloud_sync_page.dart b/lib/pages/sync/cloud_sync_page.dart index 7119808..2184350 100644 --- a/lib/pages/sync/cloud_sync_page.dart +++ b/lib/pages/sync/cloud_sync_page.dart @@ -18,13 +18,13 @@ class CloudSyncPage extends StatelessWidget { // 备份方式标题 _buildSectionTitle('选择备份方式'), const SizedBox(height: 16), - + // WebDAV 备份选项 _buildSyncOption( context, icon: Icons.storage_outlined, title: 'WebDAV 备份', - subtitle: '通过 WebDAV 协议备份到个人云盘(如坚果云、Nextcloud 等)', + subtitle: '通过 WebDAV 协议备份到个人云盘', onTap: () { Navigator.push( context, @@ -32,9 +32,9 @@ class CloudSyncPage extends StatelessWidget { ); }, ), - + const SizedBox(height: 32), - + // 说明文字 _buildInfoSection(), ], @@ -86,7 +86,8 @@ class CloudSyncPage extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + border: + Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), child: const Icon( Icons.info_outline, @@ -176,15 +177,16 @@ class CloudSyncPage extends StatelessWidget { color: enabled ? Colors.white : const Color(0xFFEEEEEE), borderRadius: BorderRadius.circular(10), border: Border.all( - color: enabled ? const Color(0xFFE8E8E8) : const Color(0xFFEEEEEE), + color: enabled + ? const Color(0xFFE8E8E8) + : const Color(0xFFEEEEEE), width: 0.5, ), ), child: Icon( icon, - color: enabled - ? const Color(0xFF666666) - : const Color(0xFF999999), + color: + enabled ? const Color(0xFF666666) : const Color(0xFF999999), size: 22, ), ), @@ -198,8 +200,8 @@ class CloudSyncPage extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: enabled - ? const Color(0xFF1A1A1A) + color: enabled + ? const Color(0xFF1A1A1A) : const Color(0xFF999999), ), ), @@ -208,8 +210,8 @@ class CloudSyncPage extends StatelessWidget { subtitle, style: TextStyle( fontSize: 13, - color: enabled - ? const Color(0xFF666666) + color: enabled + ? const Color(0xFF666666) : const Color(0xFF999999), height: 1.4, ), @@ -219,9 +221,8 @@ class CloudSyncPage extends StatelessWidget { ), Icon( Icons.chevron_right, - color: enabled - ? const Color(0xFFCCCCCC) - : const Color(0xFFE5E5E5), + color: + enabled ? const Color(0xFFCCCCCC) : const Color(0xFFE5E5E5), ), ], ), diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index db130b7..bbd6753 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -17,20 +17,20 @@ class _WebDAVSyncPageState extends State { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); final _pathController = TextEditingController(text: '/mooknote'); - + bool _isLoading = false; bool _isConfigured = false; bool _obscurePassword = true; bool _isAutoSyncEnabled = false; - int _autoSyncInterval = 5; // 默认5分钟 + int _autoSyncInterval = 5; SyncDirection _syncDirection = SyncDirection.upload; - + @override void initState() { super.initState(); _loadConfig(); } - + @override void dispose() { _urlController.dispose(); @@ -39,8 +39,7 @@ class _WebDAVSyncPageState extends State { _pathController.dispose(); super.dispose(); } - - /// 加载已保存的配置 + Future _loadConfig() async { final config = await WebDAVService.instance.getConfig(); if (config != null) { @@ -52,8 +51,7 @@ class _WebDAVSyncPageState extends State { _isConfigured = true; }); } - - // 加载自动同步设置 + final autoSyncEnabled = await WebDAVService.instance.isAutoSyncEnabled(); final autoSyncInterval = await WebDAVService.instance.getAutoSyncInterval(); setState(() { @@ -61,249 +59,210 @@ class _WebDAVSyncPageState extends State { _autoSyncInterval = autoSyncInterval; }); } - - /// 保存配置 + Future _saveConfig() async { final url = _urlController.text.trim(); final username = _usernameController.text.trim(); final password = _passwordController.text; final path = _pathController.text.trim(); - - if (url.isEmpty) { - ToastUtil.show(context, '请输入服务器地址'); - return; - } - if (username.isEmpty) { - ToastUtil.show(context, '请输入用户名'); - return; - } - if (password.isEmpty) { - ToastUtil.show(context, '请输入密码'); - return; - } - + + if (url.isEmpty) { ToastUtil.show(context, '请输入服务器地址'); return; } + if (username.isEmpty) { ToastUtil.show(context, '请输入用户名'); return; } + if (password.isEmpty) { ToastUtil.show(context, '请输入密码'); return; } + setState(() => _isLoading = true); - + try { - // 测试连接 final result = await WebDAVService.instance.testConnection( - url: url, - username: username, - password: password, - path: path, + url: url, username: username, password: password, path: path, ); - + if (!mounted) return; - + if (result['success'] == true) { - // 保存配置 await WebDAVService.instance.saveConfig( - url: url, - username: username, - password: password, - path: path, + url: url, username: username, password: password, path: path, ); - setState(() => _isConfigured = true); ToastUtil.show(context, result['message'] ?? '连接成功,配置已保存'); - - // 连接成功后停留在当前页面,不返回上级 } else { ToastUtil.show(context, result['message'] ?? '连接失败,请检查配置'); } } catch (e) { - if (mounted) { - ToastUtil.show(context, '连接失败: $e'); - } + if (mounted) ToastUtil.show(context, '连接失败: $e'); } finally { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } - - /// 安全显示 Toast - void _safeShowToast(String message) { - if (mounted) { - ToastUtil.show(context, message); - } - } - - /// 执行同步 + Future _syncData() async { setState(() => _isLoading = true); - + try { final result = await WebDAVService.instance.syncData(direction: _syncDirection); - if (!mounted) return; - + if (result.success) { final details = '上传: ${result.uploadedFiles} 文件, ${result.uploadedImages} 图片\n' '下载: ${result.downloadedFiles} 文件, ${result.downloadedImages} 图片'; - - // 如果需要重新加载数据(下载了数据库) + if (result.needReload) { - // 显示提示 ToastUtil.show(context, '数据已更新,正在重新加载...'); - - // 重新加载所有数据 final provider = context.read(); await provider.loadMovies(); await provider.loadBooks(); await provider.loadNotes(); - - if (mounted) { - _showSyncResultDialog('同步成功(数据已刷新)', details); - } + if (mounted) _showResultDialog('同步成功', details); } else { - _showSyncResultDialog('同步成功', details); + _showResultDialog('同步成功', details); } } else { ToastUtil.show(context, result.message); } } catch (e) { - if (mounted) { - ToastUtil.show(context, '同步失败: $e'); - } + if (mounted) ToastUtil.show(context, '同步失败: $e'); } finally { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } - - /// 显示同步结果对话框 - void _showSyncResultDialog(String title, String content) { + + void _showResultDialog(String title, String content) { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( backgroundColor: Colors.white, elevation: 0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: Text(title), - content: Text(content), + title: Column( + children: [ + Container( + width: 48, height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.check, color: Color(0xFF1A1A1A), size: 24), + ), + const SizedBox(height: 16), + Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(content, style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6), textAlign: TextAlign.center), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('确定'), + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(minimumSize: const Size(120, 40), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('确定', style: TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), ), ], ), ); } - - /// 切换自动同步 + Future _toggleAutoSync(bool value) async { setState(() => _isLoading = true); - try { if (value) { await WebDAVService.instance.startAutoSync(); - if (mounted) { - ToastUtil.show(context, '自动同步已开启,每 $_autoSyncInterval 分钟同步一次'); - } + if (mounted) ToastUtil.show(context, '自动同步已开启,每 $_autoSyncInterval 分钟同步一次'); } else { await WebDAVService.instance.stopAutoSync(); - if (mounted) { - ToastUtil.show(context, '自动同步已关闭'); - } + if (mounted) ToastUtil.show(context, '自动同步已关闭'); } - setState(() => _isAutoSyncEnabled = value); } catch (e) { - if (mounted) { - ToastUtil.show(context, '设置失败: $e'); - } + if (mounted) ToastUtil.show(context, '设置失败: $e'); } finally { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } - - /// 显示间隔选择器 + Future _showIntervalPicker() async { final intervals = [1, 3, 5, 10, 15, 30, 60]; - - final selected = await showDialog( + final selected = await showModalBottomSheet( context: context, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('选择同步间隔'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: intervals.length, - itemBuilder: (context, index) { - final interval = intervals[index]; - return ListTile( - title: Text('$interval 分钟'), - trailing: _autoSyncInterval == interval - ? const Icon(Icons.check, color: Color(0xFF1A1A1A)) - : null, - onTap: () => Navigator.pop(context, interval), - ); - }, - ), + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container(width: 36, height: 4, decoration: BoxDecoration(color: const Color(0xFFDDDDDD), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 12), + const Text('同步间隔', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const SizedBox(height: 8), + ...intervals.map((i) => ListTile( + title: Text('$i 分钟', style: TextStyle(fontSize: 15, fontWeight: _autoSyncInterval == i ? FontWeight.w600 : FontWeight.w400, color: const Color(0xFF1A1A1A))), + trailing: _autoSyncInterval == i ? const Icon(Icons.check, color: Color(0xFF1A1A1A), size: 20) : null, + onTap: () => Navigator.pop(ctx, i), + )), + const SizedBox(height: 8), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), - ), - ], ), ); - + if (selected != null && selected != _autoSyncInterval) { setState(() => _isLoading = true); - try { await WebDAVService.instance.setAutoSyncInterval(selected); setState(() => _autoSyncInterval = selected); - - if (mounted) { - ToastUtil.show(context, '同步间隔已设置为 $selected 分钟'); - } + if (mounted) ToastUtil.show(context, '同步间隔已设置为 $selected 分钟'); } catch (e) { - if (mounted) { - ToastUtil.show(context, '设置失败: $e'); - } + if (mounted) ToastUtil.show(context, '设置失败: $e'); } finally { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } } - - /// 清除配置 + Future _clearConfig() async { final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( backgroundColor: Colors.white, elevation: 0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('清除配置'), - content: const Text('确定要清除 WebDAV 配置吗?'), + title: Row( + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration(color: Colors.red.withOpacity(0.08), borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), + ), + const SizedBox(width: 12), + const Text('清除配置', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: const Padding( + padding: EdgeInsets.only(top: 16), + child: Text('确定要清除 WebDAV 配置吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6)), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + onPressed: () => Navigator.pop(ctx, false), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666), fontSize: 14)), ), TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('清除', style: TextStyle(color: Colors.red)), + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('清除', style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)), ), ], ), ); - + if (confirmed == true) { await WebDAVService.instance.clearConfig(); setState(() { @@ -312,570 +271,339 @@ class _WebDAVSyncPageState extends State { _passwordController.clear(); _pathController.text = '/mooknote'; _isConfigured = false; + _isAutoSyncEnabled = false; }); - if (mounted) { - ToastUtil.show(context, '配置已清除'); - } + if (mounted) ToastUtil.show(context, '配置已清除'); } } + // ── UI ────────────────────────────────────────────── + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('WebDAV 备份'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.maybePop(context), - ), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 状态提示 - if (_isConfigured) - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.check_circle, - color: Color(0xFF1A1A1A), - size: 20, - ), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '已配置 WebDAV', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - SizedBox(height: 2), - Text( - '配置已保存,可以进行数据同步', - style: TextStyle( - fontSize: 12, - color: Color(0xFF666666), - ), - ), - ], - ), - ), - ], - ), - ), - - // 配置表单 - _buildSectionTitle('服务器配置'), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - // 服务器地址 - _buildTextField( - controller: _urlController, - label: '服务器地址', - hint: 'https://dav.example.com', - icon: Icons.link, - ), - const SizedBox(height: 16), - - // 用户名 - _buildTextField( - controller: _usernameController, - label: '用户名', - hint: '请输入用户名', - icon: Icons.person_outline, - ), - const SizedBox(height: 16), - - // 密码 - _buildTextField( - controller: _passwordController, - label: '密码', - hint: '请输入密码', - icon: Icons.lock_outline, - obscureText: _obscurePassword, - suffixIcon: IconButton( - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - color: const Color(0xFF999999), - ), - onPressed: () { - setState(() => _obscurePassword = !_obscurePassword); - }, - ), - ), - const SizedBox(height: 16), - - // 同步路径 - _buildTextField( - controller: _pathController, - label: '同步路径', - hint: '/mooknote', - icon: Icons.folder_outlined, - ), - ], - ), + appBar: AppBar(title: const Text('WebDAV 备份')), + body: _isLoading && !_isConfigured + ? const Center(child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF1A1A1A))) + : ListView( + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + const SizedBox(height: 8), + + // 已连接提示 + if (_isConfigured) _buildConnectedBanner(), + + // 服务器配置 + _buildSectionLabel('服务器配置'), + const SizedBox(height: 16), + _buildInput( + controller: _urlController, + hint: '服务器地址,如 https://dav.example.com', + icon: Icons.link, + ), + const SizedBox(height: 12), + _buildInput( + controller: _usernameController, + hint: '用户名', + icon: Icons.person_outline, + ), + const SizedBox(height: 12), + _buildInput( + controller: _passwordController, + hint: '密码', + icon: Icons.lock_outline, + obscure: _obscurePassword, + suffix: GestureDetector( + onTap: () => setState(() => _obscurePassword = !_obscurePassword), + child: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility, size: 20, color: const Color(0xFFBBBBBB)), ), - - const SizedBox(height: 24), - - // 保存配置按钮 - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _saveConfig, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A1A1A), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - '测试并保存', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), + ), + const SizedBox(height: 12), + _buildInput( + controller: _pathController, + hint: '同步路径,如 /mooknote', + icon: Icons.folder_outlined, + ), + const SizedBox(height: 24), + + // 测试并保存 + _buildBtn('测试并保存', onTap: _isLoading ? null : _saveConfig), + const SizedBox(height: 40), + + if (_isConfigured) ...[ + // 自动同步 + _buildSectionLabel('自动同步'), + const SizedBox(height: 12), + _buildSwitchRow( + icon: Icons.sync, + label: '自动同步', + sub: _isAutoSyncEnabled ? '每 $_autoSyncInterval 分钟自动同步' : '关闭', + value: _isAutoSyncEnabled, + onChanged: _isLoading ? null : _toggleAutoSync, ), - - if (_isConfigured) ...[ - const SizedBox(height: 32), - - // 自动同步设置 - _buildSectionTitle('自动同步'), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '启用自动同步', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(height: 4), - Text( - '修改后自动上传,每 $_autoSyncInterval 分钟同步一次', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF666666), - ), - ), - ], - ), - ), - Switch( - value: _isAutoSyncEnabled, - onChanged: _isLoading ? null : _toggleAutoSync, - activeTrackColor: const Color(0xFF1A1A1A), - ), - ], - ), - - if (_isAutoSyncEnabled) ...[ - const SizedBox(height: 16), - const Divider(height: 1, color: Color(0xFFE8E8E8)), - const SizedBox(height: 16), - - Row( - children: [ - const Text( - '同步间隔', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(width: 16), - Expanded( - child: GestureDetector( - onTap: _isLoading ? null : _showIntervalPicker, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '$_autoSyncInterval 分钟', - style: const TextStyle( - fontSize: 14, - color: Color(0xFF1A1A1A), - ), - ), - const Icon( - Icons.arrow_forward_ios, - size: 14, - color: Color(0xFF999999), - ), - ], - ), - ), - ), - ), - ], - ), - ], - ], - ), - ), - - const SizedBox(height: 32), - - // 同步操作区域 - _buildSectionTitle('手动同步'), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '同步方向', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _buildDirectionButton( - '上传到云端', - SyncDirection.upload, - Icons.upload, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildDirectionButton( - '下载到本地', - SyncDirection.download, - Icons.download, - ), - ), - ], - ), - - const SizedBox(height: 20), - - // 同步按钮 - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _syncData, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A1A1A), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - '立即同步', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 16), - - // 清除配置按钮 - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: _isLoading ? null : _clearConfig, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - '清除配置', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), + if (_isAutoSyncEnabled) ...[ + const SizedBox(height: 4), + _buildTappableRow( + label: '同步间隔', + value: '$_autoSyncInterval 分钟', + onTap: _isLoading ? null : _showIntervalPicker, ), ], - + const SizedBox(height: 32), - - // 说明 - _buildInfoSection(), + + // 手动同步 + _buildSectionLabel('手动同步'), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: _buildDirectionChip('上传到云端', SyncDirection.upload, Icons.upload)), + const SizedBox(width: 12), + Expanded(child: _buildDirectionChip('下载到本地', SyncDirection.download, Icons.download)), + ], + ), + const SizedBox(height: 20), + _buildBtn('立即同步', onTap: _isLoading ? null : _syncData, loading: _isLoading), + + const SizedBox(height: 32), + + // 清除配置 + _buildTextBtn('清除配置', onTap: _isLoading ? null : _clearConfig), + const SizedBox(height: 8), ], - ), + + const SizedBox(height: 32), + _buildTips(), + const SizedBox(height: 60), + ], ), ); } - /// 构建区块标题 - Widget _buildSectionTitle(String title) { - return Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - ], - ); - } + // ── widgets ───────────────────────────────────────── - /// 构建信息说明区域 - Widget _buildInfoSection() { + Widget _buildConnectedBanner() { return Container( - padding: const EdgeInsets.all(20), + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: const Color(0xFFF8F8F8), - borderRadius: BorderRadius.circular(12), + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: const Icon( - Icons.info_outline, - size: 18, - color: Color(0xFF666666), - ), - ), - const SizedBox(width: 12), - const Text( - '使用说明', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - ], + Container( + width: 6, height: 6, + decoration: const BoxDecoration(color: Color(0xFF4CAF50), shape: BoxShape.circle), ), - const SizedBox(height: 16), - _buildInfoItem('支持坚果云、Nextcloud、AList 等 WebDAV 服务'), - const SizedBox(height: 10), - _buildInfoItem('服务器地址需包含协议(http:// 或 https://)'), - const SizedBox(height: 10), - _buildInfoItem('数据文件和图片分开存储,支持多设备同步'), - const SizedBox(height: 10), - _buildInfoItem('开启自动同步后,修改将自动上传云端'), + const SizedBox(width: 10), + Expanded( + child: Text( + _urlController.text, + style: const TextStyle(fontSize: 13, color: Color(0xFF666666)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Text('已连接', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), ], ), ); } - /// 构建信息项 - Widget _buildInfoItem(String text) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(top: 7), - decoration: BoxDecoration( - color: const Color(0xFF999999), - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF666666), - height: 1.5, - ), - ), - ), - ], - ); + Widget _buildSectionLabel(String text) { + return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF999999), letterSpacing: 0.5)); } - - Widget _buildTextField({ + + Widget _buildInput({ required TextEditingController controller, - required String label, required String hint, required IconData icon, - bool obscureText = false, - Widget? suffixIcon, + bool obscure = false, + Widget? suffix, }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), + return TextField( + controller: controller, + obscureText: obscure, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 4, right: 8), + child: Icon(icon, size: 20, color: const Color(0xFFBBBBBB)), ), - const SizedBox(height: 8), - TextField( - controller: controller, - obscureText: obscureText, - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Color(0xFFAAAAAA)), - prefixIcon: Icon(icon, color: const Color(0xFF999999)), - suffixIcon: suffixIcon, - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE8E8E8)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE8E8E8)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF1A1A1A)), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), + prefixIconConstraints: const BoxConstraints(minWidth: 44), + suffixIcon: suffix != null ? Padding( + padding: const EdgeInsets.only(right: 8), + child: suffix, + ) : null, + filled: true, + fillColor: const Color(0xFFF8F8F8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, ), - ], + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + ), ); } - - Widget _buildDirectionButton(String label, SyncDirection direction, IconData icon) { - final isSelected = _syncDirection == direction; + + Widget _buildBtn(String text, {VoidCallback? onTap, bool loading = false}) { + final disabled = onTap == null; return GestureDetector( - onTap: () => setState(() => _syncDirection = direction), + onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 15), decoration: BoxDecoration( - color: isSelected ? const Color(0xFF1A1A1A) : Colors.white, + color: disabled ? const Color(0xFFDDDDDD) : const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8), - width: 0.5, + ), + child: Center( + child: loading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))) + : Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white)), + ), + ), + ); + } + + Widget _buildTextBtn(String text, {VoidCallback? onTap}) { + return GestureDetector( + onTap: onTap, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(text, style: TextStyle(fontSize: 14, color: onTap == null ? const Color(0xFFCCCCCC) : const Color(0xFF999999)))), + ), + ); + } + + Widget _buildSwitchRow({ + required IconData icon, + required String label, + required String sub, + required bool value, + required ValueChanged? onChanged, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, size: 20, color: value ? const Color(0xFF1A1A1A) : const Color(0xFFBBBBBB)), + const SizedBox(width: 12), + Expanded( + child: Text( + value ? sub : label, + style: TextStyle(fontSize: 14, color: value ? const Color(0xFF666666) : const Color(0xFF1A1A1A)), + ), ), + Switch( + value: value, + onChanged: onChanged, + activeColor: const Color(0xFF1A1A1A), + activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3), + inactiveThumbColor: Colors.white, + inactiveTrackColor: const Color(0xFFDDDDDD), + ), + ], + ), + ); + } + + Widget _buildTappableRow({ + required String label, + required String value, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - icon, - size: 18, - color: isSelected ? Colors.white : const Color(0xFF666666), - ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : const Color(0xFF666666), - ), - ), + const SizedBox(width: 32), + Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF999999))), + const Spacer(), + Text(value, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Color(0xFFCCCCCC)), ], ), ), ); } + + Widget _buildDirectionChip(String label, SyncDirection dir, IconData icon) { + final selected = _syncDirection == dir; + return GestureDetector( + onTap: () => setState(() => _syncDirection = dir), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : const Color(0xFF999999)), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: selected ? Colors.white : const Color(0xFF666666))), + ], + ), + ), + ); + } + + Widget _buildTips() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('支持的服务', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF999999), letterSpacing: 0.5)), + const SizedBox(height: 10), + _tip('坚果云、Nextcloud、AList 等 WebDAV 服务'), + _tip('服务器地址需包含 https://'), + _tip('首次同步可能需要较长时间'), + ], + ), + ); + } + + Widget _tip(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 8), + child: Icon(Icons.circle, size: 4, color: Color(0xFFCCCCCC)), + ), + const SizedBox(width: 8), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF888888), height: 1.5))), + ], + ), + ); + } } diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart new file mode 100644 index 0000000..e66af87 --- /dev/null +++ b/lib/pages/tag_management_page.dart @@ -0,0 +1,659 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../utils/toast_util.dart'; + +class TagManagementPage extends StatefulWidget { + const TagManagementPage({super.key}); + + @override + State createState() => _TagManagementPageState(); +} + +class _TagManagementPageState extends State { + int _currentIndex = 0; + + static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag']; + static const _typeLabels = ['影视类型', '书籍类型', '笔记标签']; + + final Map>> _tagCache = {}; + + @override + void initState() { + super.initState(); + _loadTags(_tabTypes[0]); + } + + Future _loadTags(String type) async { + final provider = context.read(); + final tags = await provider.getTags(type); + if (mounted) { + setState(() => _tagCache[type] = tags); + } + } + + String get _currentType => _tabTypes[_currentIndex]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('标签管理'), + ), + body: Column( + children: [ + const SizedBox(height: 8), + _buildTabSelector(), + const SizedBox(height: 20), + Expanded( + child: _buildTagList(_currentType), + ), + ], + ), + floatingActionButton: GestureDetector( + onTap: _showAddDialog, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 18, color: Colors.white), + const SizedBox(width: 6), + Text( + '添加${_typeLabels[_currentIndex]}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ); + } + + /// 胶囊式 Tab 选择器 + Widget _buildTabSelector() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF2F2F2), + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: List.generate(3, (i) { + final selected = _currentIndex == i; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() => _currentIndex = i); + _loadTags(_tabTypes[i]); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(20), + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + _typeLabels[i], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected + ? const Color(0xFF1A1A1A) + : const Color(0xFF888888), + ), + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildTagList(String type) { + final tags = _tagCache[type] ?? []; + + if (tags.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.label_outline, + size: 24, color: Color(0xFFCCCCCC)), + ), + const SizedBox(height: 16), + const Text('暂无标签', + style: TextStyle( + fontSize: 14, + color: Color(0xFFBBBBBB), + fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + const Text('点击下方按钮添加', + style: TextStyle(fontSize: 12, color: Color(0xFFD5D5D5))), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: tags.map((tag) => _buildTagChip(tag)).toList(), + ), + ); + } + + Widget _buildTagChip(Map tag) { + final name = tag['name'] as String; + + return GestureDetector( + onLongPress: () => _showRenameDialog(tag), + onTap: () => _showDeleteDialog(tag), + child: Container( + padding: const EdgeInsets.only(left: 14, right: 6, top: 8, bottom: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECECEC), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(width: 6), + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: const Color(0xFFECECEC), + borderRadius: BorderRadius.circular(11), + ), + child: const Icon(Icons.close, + size: 12, color: Color(0xFF999999)), + ), + ], + ), + ), + ); + } + + void _showAddDialog() { + final controller = TextEditingController(); + final type = _currentType; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + '添加${_typeLabels[_currentIndex]}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A)), + ), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: '输入标签名称', + hintStyle: + const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + filled: true, + fillColor: const Color(0xFFFAFAFA), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: Color(0xFF1A1A1A), width: 1), + ), + ), + onSubmitted: (value) => + _doAddTag(ctx, controller.text.trim(), type), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消', + style: TextStyle(color: Color(0xFF999999))), + ), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _doAddTag(ctx, controller.text.trim(), type), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text('添加', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500)), + ), + ), + ), + ), + ], + ), + ); + } + + Future _doAddTag( + BuildContext ctx, String name, String type) async { + if (name.isEmpty) return; + try { + await context.read().addTag(name, type); + } catch (e) { + if (ctx.mounted) { + ToastUtil.show(ctx, '添加失败:该标签已存在'); + } + return; + } + if (ctx.mounted) { + Navigator.pop(ctx); + ToastUtil.show(context, '添加成功'); + } + await _loadTags(type); + } + + void _showRenameDialog(Map tag) { + final controller = TextEditingController(text: tag['name'] as String); + final tagId = tag['id'] as String; + final type = tag['type'] as String; + final oldName = tag['name'] as String; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + '重命名标签', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A)), + ), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: '输入新名称', + hintStyle: + const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + filled: true, + fillColor: const Color(0xFFFAFAFA), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: Color(0xFF1A1A1A), width: 1), + ), + ), + onSubmitted: (value) => + _doRenameTag(ctx, tagId, value.trim(), type, oldName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消', + style: TextStyle(color: Color(0xFF999999))), + ), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _doRenameTag(ctx, tagId, controller.text.trim(), type, oldName), + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text('确定', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500)), + ), + ), + ), + ), + ], + ), + ); + } + + Future _doRenameTag(BuildContext ctx, String tagId, String newName, + String type, String oldName) async { + if (newName.isEmpty || newName == oldName) { + if (ctx.mounted) Navigator.pop(ctx); + return; + } + final success = + await context.read().renameTag(tagId, newName, type); + if (ctx.mounted) { + Navigator.pop(ctx); + ToastUtil.show( + context, success ? '重命名成功' : '重命名失败:标签名已存在'); + } + if (success) await _loadTags(type); + } + + void _showDeleteDialog(Map tag) { + final tagId = tag['id'] as String; + final type = tag['type'] as String; + final name = tag['name'] as String; + String? selectedAction = 'remove'; + final replacementController = TextEditingController(); + + final otherTags = (_tagCache[type] ?? []) + .where((t) => t['id'] != tagId) + .map((t) => t['name'] as String) + .toList(); + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: Colors.white, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF666666)), + ), + ), + const SizedBox(width: 10), + const Text( + '删除标签', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A)), + ), + ], + ), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.45, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + const Text( + '删除后对已有条目的影响:', + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), + ), + const SizedBox(height: 12), + _buildDeleteOption( + value: 'remove', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() => selectedAction = v), + title: '从所有条目中移除该标签', + ), + const SizedBox(height: 4), + _buildDeleteOption( + value: 'replace', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() => selectedAction = v), + title: '替换为其他标签', + ), + if (selectedAction == 'replace') + Padding( + padding: const EdgeInsets.only(left: 40, top: 10), + child: otherTags.isNotEmpty + ? Wrap( + spacing: 8, + runSpacing: 8, + children: otherTags.map((t) { + final isSelected = + replacementController.text == t; + return GestureDetector( + onTap: () { + replacementController.text = + isSelected ? '' : t; + setDialogState(() {}); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF1A1A1A) + : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? const Color(0xFF1A1A1A) + : const Color(0xFFE8E8E8), + width: 0.5, + ), + ), + child: Text( + t, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isSelected + ? Colors.white + : const Color(0xFF555555), + ), + ), + ), + ); + }).toList(), + ) + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + ), + child: const Text('无其他标签可替换', + style: TextStyle( + fontSize: 13, + color: Color(0xFFAAAAAA))), + ), + ), + ], + ), + ), + ), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消', + style: TextStyle(color: Color(0xFF999999))), + ), + Container( + decoration: BoxDecoration( + color: const Color(0xFFE53935), + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + String? replacement; + if (selectedAction == 'replace') { + replacement = + replacementController.text.trim().isNotEmpty + ? replacementController.text.trim() + : null; + if (replacement == null) return; + } + Navigator.pop(ctx, { + 'replacement': replacement, + }); + }, + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text('删除', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500)), + ), + ), + ), + ), + ], + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + ), + ), + ).then((result) async { + if (result == null) return; + final replacement = result['replacement'] as String?; + + await context + .read() + .deleteTag(tagId, type, replacementName: replacement); + if (mounted) { + ToastUtil.show(context, '删除成功'); + } + await _loadTags(type); + }); + } + + Widget _buildDeleteOption({ + required String value, + required String? groupValue, + required ValueChanged onChanged, + required String title, + }) { + final selected = value == groupValue; + return GestureDetector( + onTap: () => onChanged(value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: selected ? const Color(0xFFFAFAFA) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFEEEEEE), + width: selected ? 1 : 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected + ? const Color(0xFF1A1A1A) + : const Color(0xFFCCCCCC), + width: selected ? 5 : 1.5, + ), + ), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + color: + selected ? const Color(0xFF1A1A1A) : const Color(0xFF666666), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index b0a0685..2384dfa 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -7,6 +7,7 @@ import '../utils/movie/movie_review_dao.dart'; import '../utils/movie/movie_poster_dao.dart'; import '../utils/book/book_review_dao.dart'; import '../utils/book/book_excerpt_dao.dart'; +import '../utils/tag/tag_dao.dart'; import '../utils/image_path_helper.dart'; /// 应用全局状态管理 @@ -19,6 +20,7 @@ class AppProvider extends ChangeNotifier { final MoviePosterDao _posterDao = MoviePosterDao(); final BookReviewDao _bookReviewDao = BookReviewDao(); final BookExcerptDao _bookExcerptDao = BookExcerptDao(); + final TagDao _tagDao = TagDao(); // 数据列表 List _movies = []; @@ -380,4 +382,41 @@ class AppProvider extends ChangeNotifier { await loadBooks(); await loadNotes(); } + + // ========== 标签管理方法 ========== + + Future>> getTags(String type) async { + return await _tagDao.getTagsByType(type); + } + + Future addTag(String name, String type) async { + final id = await _tagDao.addTag(name, type); + await _reloadByTagType(type); + return id; + } + + Future renameTag(String tagId, String newName, String type) async { + final result = await _tagDao.renameTag(tagId, newName); + if (result) { + await _reloadByTagType(type); + } + return result; + } + + Future deleteTag(String tagId, String type, + {String? replacementName}) async { + await _tagDao.deleteTag(tagId, replacementName: replacementName); + await _reloadByTagType(type); + } + + Future _reloadByTagType(String type) async { + switch (type) { + case 'movie_genre': + await loadMovies(); + case 'book_genre': + await loadBooks(); + case 'note_tag': + await loadNotes(); + } + } } diff --git a/lib/utils/data_migration.dart b/lib/utils/data_migration.dart deleted file mode 100644 index a5af499..0000000 --- a/lib/utils/data_migration.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart' as p; -import '../models/data_models.dart'; -import 'database_helper.dart'; -import 'storage_helper.dart'; - -/// 数据迁移帮助类:将旧版文件系统数据迁移到 SQLite 数据库 -class DataMigration { - final StorageHelper _storage = StorageHelper.instance; - final DatabaseHelper _db = DatabaseHelper.instance; - - static bool _hasMigrated = false; - - /// 执行数据迁移(幂等,只会执行一次) - Future migrateIfNeeded() async { - if (_hasMigrated) return; - _hasMigrated = true; - - try { - await _migrateMovies(); - await _migrateBooks(); - await _migrateNotes(); - debugPrint('数据迁移完成'); - } catch (e, stack) { - debugPrint('数据迁移失败: $e'); - debugPrint('堆栈: $stack'); - } - } - - /// 迁移影视数据 - Future _migrateMovies() async { - final moviesDirPath = await _storage.moviesDir; - final movieDirs = await _listSubdirNames(moviesDirPath); - if (movieDirs.isEmpty) return; - - debugPrint('发现 ${movieDirs.length} 个影视目录,开始迁移...'); - final db = await _db.database; - - for (final dirName in movieDirs) { - try { - final dirPath = p.join(moviesDirPath, dirName); - final dataPath = '$dirPath/data.json'; - final data = await _readJsonFile(dataPath); - if (data == null) continue; - - final movie = Movie.fromJson(data); - await db.insert( - 'movies', - _movieToMap(movie), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - - // 迁移影评 - await _migrateMovieReviews(dirPath, movie.id); - // 迁移海报 - await _migrateMoviePosters(dirPath, movie.id); - } catch (e) { - debugPrint('迁移影视 $dirName 失败: $e'); - } - } - } - - /// 迁移影评 - Future _migrateMovieReviews(String movieDirPath, String movieId) async { - final reviewsDir = p.join(movieDirPath, 'reviews'); - if (!await Directory(reviewsDir).exists()) return; - - final files = await _listJsonFiles(reviewsDir); - final db = await _db.database; - - for (final data in files) { - try { - final review = MovieReview.fromJson(data); - await db.insert( - 'movie_reviews', - _movieReviewToMap(review), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } catch (e) { - debugPrint('迁移影评失败: $e'); - } - } - } - - /// 迁移海报 - Future _migrateMoviePosters(String movieDirPath, String movieId) async { - final postersDir = p.join(movieDirPath, 'posters'); - if (!await Directory(postersDir).exists()) return; - - final files = await _listJsonFiles(postersDir); - final db = await _db.database; - - for (final data in files) { - try { - final poster = MoviePoster.fromJson(data); - await db.insert( - 'movie_posters', - _moviePosterToMap(poster), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } catch (e) { - debugPrint('迁移海报失败: $e'); - } - } - } - - /// 迁移书籍数据 - Future _migrateBooks() async { - final booksDirPath = await _storage.booksDir; - final bookDirs = await _listSubdirNames(booksDirPath); - if (bookDirs.isEmpty) return; - - debugPrint('发现 ${bookDirs.length} 个书籍目录,开始迁移...'); - final db = await _db.database; - - for (final dirName in bookDirs) { - try { - final dirPath = p.join(booksDirPath, dirName); - final dataPath = '$dirPath/data.json'; - final data = await _readJsonFile(dataPath); - if (data == null) continue; - - final book = Book.fromJson(data); - await db.insert( - 'books', - _bookToMap(book), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - - // 迁移书评 - await _migrateBookReviews(dirPath, book.id); - // 迁移摘抄 - await _migrateBookExcerpts(dirPath, book.id); - } catch (e) { - debugPrint('迁移书籍 $dirName 失败: $e'); - } - } - } - - /// 迁移书评 - Future _migrateBookReviews(String bookDirPath, String bookId) async { - final reviewsDir = p.join(bookDirPath, 'reviews'); - if (!await Directory(reviewsDir).exists()) return; - - final files = await _listJsonFiles(reviewsDir); - final db = await _db.database; - - for (final data in files) { - try { - final review = BookReview.fromJson(data); - await db.insert( - 'book_reviews', - _bookReviewToMap(review), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } catch (e) { - debugPrint('迁移书评失败: $e'); - } - } - } - - /// 迁移摘抄 - Future _migrateBookExcerpts(String bookDirPath, String bookId) async { - final excerptsDir = p.join(bookDirPath, 'excerpts'); - if (!await Directory(excerptsDir).exists()) return; - - final files = await _listJsonFiles(excerptsDir); - final db = await _db.database; - - for (final data in files) { - try { - final excerpt = BookExcerpt.fromJson(data); - await db.insert( - 'book_excerpts', - _bookExcerptToMap(excerpt), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } catch (e) { - debugPrint('迁移摘抄失败: $e'); - } - } - } - - /// 迁移笔记数据 - Future _migrateNotes() async { - final notesDirPath = await _storage.notesDir; - final noteDirs = await _listSubdirNames(notesDirPath); - if (noteDirs.isEmpty) return; - - debugPrint('发现 ${noteDirs.length} 个笔记目录,开始迁移...'); - final db = await _db.database; - - for (final dirName in noteDirs) { - try { - final dirPath = p.join(notesDirPath, dirName); - final dataPath = '$dirPath/data.json'; - final data = await _readJsonFile(dataPath); - if (data == null) continue; - - final note = Note.fromJson(data); - await db.insert( - 'notes', - _noteToMap(note), - conflictAlgorithm: ConflictAlgorithm.ignore, - ); - } catch (e) { - debugPrint('迁移笔记 $dirName 失败: $e'); - } - } - } - - // ========== 转换方法 ========== - - Map _movieToMap(Movie movie) { - return { - 'id': movie.id, - 'title': movie.title, - 'poster_path': movie.posterPath, - 'release_date': movie.releaseDate?.toIso8601String(), - 'directors': jsonEncode(movie.directors), - 'writers': jsonEncode(movie.writers), - 'actors': jsonEncode(movie.actors), - 'genres': jsonEncode(movie.genres), - 'alternate_titles': jsonEncode(movie.alternateTitles), - 'summary': movie.summary, - 'rating': movie.rating, - 'status': movie.status, - 'watch_date': movie.watchDate?.toIso8601String(), - 'created_at': movie.createdAt.toIso8601String(), - 'updated_at': movie.updatedAt.toIso8601String(), - 'is_deleted': movie.isDeleted ? 1 : 0, - }; - } - - Map _bookToMap(Book book) { - return { - 'id': book.id, - 'title': book.title, - 'cover_path': book.coverPath, - 'authors': jsonEncode(book.authors), - 'alternate_titles': jsonEncode(book.alternateTitles), - 'publisher': book.publisher, - 'genres': jsonEncode(book.genres), - 'summary': book.summary, - 'rating': book.rating, - 'status': book.status, - 'isbn': book.isbn, - 'publish_date': book.publishDate?.toIso8601String(), - 'created_at': book.createdAt.toIso8601String(), - 'updated_at': book.updatedAt.toIso8601String(), - 'is_deleted': book.isDeleted ? 1 : 0, - }; - } - - Map _noteToMap(Note note) { - return { - 'id': note.id, - 'content': note.content, - 'content_type': note.contentType, - 'tags': jsonEncode(note.tags), - 'images': jsonEncode(note.images), - 'created_at': note.createdAt.toIso8601String(), - 'updated_at': note.updatedAt.toIso8601String(), - 'is_deleted': note.isDeleted ? 1 : 0, - }; - } - - Map _movieReviewToMap(MovieReview review) { - return { - 'id': review.id, - 'movie_id': review.movieId, - 'content': review.content, - 'reviewer': review.reviewer, - 'source': review.source, - 'review_type': review.reviewType, - 'is_deleted': review.isDeleted ? 1 : 0, - 'created_at': review.createdAt.toIso8601String(), - 'updated_at': review.updatedAt.toIso8601String(), - }; - } - - Map _moviePosterToMap(MoviePoster poster) { - return { - 'id': poster.id, - 'movie_id': poster.movieId, - 'poster_path': poster.posterPath, - 'is_deleted': poster.isDeleted ? 1 : 0, - 'created_at': poster.createdAt.toIso8601String(), - }; - } - - Map _bookReviewToMap(BookReview review) { - return { - 'id': review.id, - 'book_id': review.bookId, - 'content': review.content, - 'reviewer': review.reviewer, - 'source': review.source, - 'review_type': review.reviewType, - 'is_deleted': review.isDeleted ? 1 : 0, - 'created_at': review.createdAt.toIso8601String(), - 'updated_at': review.updatedAt.toIso8601String(), - }; - } - - Map _bookExcerptToMap(BookExcerpt excerpt) { - return { - 'id': excerpt.id, - 'book_id': excerpt.bookId, - 'chapter': excerpt.chapter, - 'content': excerpt.content, - 'comment': excerpt.comment, - 'is_deleted': excerpt.isDeleted ? 1 : 0, - 'created_at': excerpt.createdAt.toIso8601String(), - 'updated_at': excerpt.updatedAt.toIso8601String(), - }; - } - - // ========== 辅助方法 ========== - - /// 列出子目录名 - Future> _listSubdirNames(String dirPath) async { - try { - final dir = Directory(dirPath); - if (!await dir.exists()) return []; - final entities = await dir.list().toList(); - return entities - .whereType() - .map((e) => p.basename(e.path)) - .toList(); - } catch (e) { - return []; - } - } - - /// 读取 JSON 文件 - Future?> _readJsonFile(String path) async { - try { - final file = File(path); - if (!await file.exists()) return null; - final content = await file.readAsString(); - return jsonDecode(content) as Map; - } catch (e) { - return null; - } - } - - /// 列出目录中的 JSON 文件并解析 - Future>> _listJsonFiles(String dirPath) async { - try { - final dir = Directory(dirPath); - if (!await dir.exists()) return []; - - final files = await dir - .list() - .where((entity) => entity is File && entity.path.endsWith('.json')) - .toList(); - - final results = >[]; - for (final file in files) { - final data = await _readJsonFile(file.path); - if (data != null) results.add(data); - } - return results; - } catch (e) { - return []; - } - } -} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 20f9c25..1cb2c01 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -1,5 +1,6 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; +import '../models/data_models.dart'; /// 数据库帮助类 - 管理数据库的创建和版本控制 class DatabaseHelper { @@ -31,7 +32,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 12, + version: 13, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -86,6 +87,9 @@ class DatabaseHelper { // 确保notes表有title列 await _upgradeNotesTableV12(db); } + if (oldVersion < 13) { + await _upgradeToV13(db); + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -119,12 +123,72 @@ class DatabaseHelper { // 检查是否存在 title 列 final columns = await db.rawQuery('PRAGMA table_info(notes)'); final hasTitle = columns.any((col) => col['name'] == 'title'); - + if (!hasTitle) { await db.execute('ALTER TABLE notes ADD COLUMN title TEXT DEFAULT \'\''); } } + /// 升级到V13:创建标签表并回填已有数据 + Future _upgradeToV13(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(name, type) + ) + '''); + await _backfillTags(db); + } + + Future _backfillTags(Database db) async { + final now = DateTime.now().toIso8601String(); + int counter = 0; + + Future insertTag(String name, String type) async { + try { + await db.insert('tags', { + 'id': 'tag_${DateTime.now().millisecondsSinceEpoch}_${counter++}', + 'name': name, + 'type': type, + 'created_at': now, + }); + } catch (_) { + // 忽略 UNIQUE 约束冲突 + } + } + + // 回填影视类型 + final movies = await db.query('movies', + where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); + for (final row in movies) { + for (final genre in Movie.parseStringList(row['genres'])) { + await insertTag(genre, 'movie_genre'); + } + } + + // 回填书籍类型 + final books = await db.query('books', + where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); + for (final row in books) { + for (final genre in Movie.parseStringList(row['genres'])) { + await insertTag(genre, 'book_genre'); + } + } + + // 回填笔记标签 + final notes = await db.query('notes', + where: 'tags IS NOT NULL AND tags != ? AND tags != ?', + whereArgs: ['[]', '']); + for (final row in notes) { + for (final tag in Movie.parseStringList(row['tags'])) { + await insertTag(tag, 'note_tag'); + } + } + } + /// 升级notes表到V9(添加图片字段) Future _upgradeNotesTableV9(Database db) async { // 检查是否存在 images 列 @@ -487,6 +551,17 @@ class DatabaseHelper { FOREIGN KEY (book_id) REFERENCES books (id) ) '''); + + // 标签表 + await db.execute(''' + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(name, type) + ) + '''); } // 关闭数据库 diff --git a/lib/utils/sync/webdav_service.dart b/lib/utils/sync/webdav_service.dart index df30773..6ea0e3d 100644 --- a/lib/utils/sync/webdav_service.dart +++ b/lib/utils/sync/webdav_service.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; +import 'package:archive/archive_io.dart'; import '../database_helper.dart'; import '../user_prefs.dart'; @@ -380,7 +381,7 @@ class WebDAVService { return result; } - /// 双向同步图片(基于文件存在性和修改时间) + /// 双向同步图片(下载远程 zip 合并 -> 打包上传本地) Future<_ImageSyncResult> _syncImagesBidirectional( http.Client client, String imagesUrl, @@ -389,54 +390,34 @@ class WebDAVService { ) async { int uploaded = 0; int downloaded = 0; - + try { - final appDir = await getApplicationDocumentsDirectory(); - final localImagesDir = Directory('${appDir.path}/images'); - - if (!await localImagesDir.exists()) { - await localImagesDir.create(recursive: true); + final zipUrl = '${imagesUrl.substring(0, imagesUrl.lastIndexOf('/'))}/images.zip'; + final tempDir = await getTemporaryDirectory(); + final tempZip = File(p.join(tempDir.path, 'images_bidir.zip')); + + final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip); + if (downloadSuccess) { + await _extractImagesZip(tempZip); + downloaded = 1; + try { await tempZip.delete(); } catch (_) {} } - - // 获取本地所有图片 - final localImages = {}; - await _collectLocalImages(localImagesDir, localImages, ''); - - // 获取远程所有图片 - final remoteImages = await _listRemoteImagesRecursive(client, imagesUrl, username, password, ''); - - // 上传本地有但远程没有的 - for (final entry in localImages.entries) { - final relativePath = entry.key; - if (!remoteImages.contains(relativePath)) { - final remoteUrl = '$imagesUrl/$relativePath'; - final parentPath = p.dirname(relativePath); - if (parentPath != '.' && parentPath.isNotEmpty) { - await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password); - } - final success = await _uploadFile(client, remoteUrl, username, password, entry.value); - if (success) uploaded++; - } - } - - // 下载远程有但本地没有的 - for (final relativePath in remoteImages) { - if (!localImages.containsKey(relativePath)) { - final remoteUrl = '$imagesUrl/$relativePath'; - final localFile = File('${localImagesDir.path}/$relativePath'); - await localFile.parent.create(recursive: true); - final success = await _downloadFile(client, remoteUrl, username, password, localFile); - if (success) downloaded++; - } + + final zipFile = await _createImagesZip(); + if (await zipFile.exists()) { + final uploadSuccess = await _uploadFile(client, zipUrl, username, password, zipFile); + if (uploadSuccess) uploaded = 1; } + try { await zipFile.delete(); } catch (_) {} } catch (e) { - // 忽略错误 + // ignore } - + return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); } + - /// 同步头像目录 + /// 同步头像目录(zip 打包传输) Future<_ImageSyncResult> _syncAvatars( http.Client client, String avatarsUrl, @@ -446,51 +427,36 @@ class WebDAVService { ) async { int uploaded = 0; int downloaded = 0; - + try { - final appDir = await getApplicationDocumentsDirectory(); - final localAvatarsDir = Directory('${appDir.path}/avatars'); - - if (!await localAvatarsDir.exists()) { - if (direction == SyncDirection.download) { - await localAvatarsDir.create(recursive: true); - } else { - return _ImageSyncResult(uploaded: 0, downloaded: 0); - } - } - - final localAvatars = {}; - await _collectLocalImages(localAvatarsDir, localAvatars, ''); - - final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, ''); - + final zipUrl = '${avatarsUrl.substring(0, avatarsUrl.lastIndexOf('/'))}/avatars.zip'; + if (direction == SyncDirection.upload) { - for (final entry in localAvatars.entries) { - final remoteUrl = '$avatarsUrl/${entry.key}'; - final parentPath = p.dirname(entry.key); - if (parentPath != '.' && parentPath.isNotEmpty) { - await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password); - } - final success = await _uploadFile(client, remoteUrl, username, password, entry.value); - if (success) uploaded++; + final zipFile = await _createAvatarsZip(); + if (await zipFile.exists()) { + final success = await _uploadFile(client, zipUrl, username, password, zipFile); + if (success) uploaded = 1; } + try { await zipFile.delete(); } catch (_) {} } else if (direction == SyncDirection.download) { - for (final relativePath in remoteAvatars) { - final remoteUrl = '$avatarsUrl/$relativePath'; - final localFile = File('${localAvatarsDir.path}/$relativePath'); - await localFile.parent.create(recursive: true); - final success = await _downloadFile(client, remoteUrl, username, password, localFile); - if (success) downloaded++; + final tempDir = await getTemporaryDirectory(); + final tempZip = File(p.join(tempDir.path, 'avatars_dl.zip')); + final success = await _downloadFile(client, zipUrl, username, password, tempZip); + if (success) { + await _extractAvatarsZip(tempZip); + downloaded = 1; } + try { await tempZip.delete(); } catch (_) {} } } catch (e) { - // 忽略错误 + // ignore } - + return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); } + - /// 双向同步头像目录 + /// 双向同步头像目录(下载远程 zip 合并 -> 打包上传本地) Future<_ImageSyncResult> _syncAvatarsBidirectional( http.Client client, String avatarsUrl, @@ -499,47 +465,32 @@ class WebDAVService { ) async { int uploaded = 0; int downloaded = 0; - + try { - final appDir = await getApplicationDocumentsDirectory(); - final localAvatarsDir = Directory('${appDir.path}/avatars'); - - if (!await localAvatarsDir.exists()) { - await localAvatarsDir.create(recursive: true); + final zipUrl = '${avatarsUrl.substring(0, avatarsUrl.lastIndexOf('/'))}/avatars.zip'; + final tempDir = await getTemporaryDirectory(); + final tempZip = File(p.join(tempDir.path, 'avatars_bidir.zip')); + + final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip); + if (downloadSuccess) { + await _extractAvatarsZip(tempZip); + downloaded = 1; + try { await tempZip.delete(); } catch (_) {} } - - final localAvatars = {}; - await _collectLocalImages(localAvatarsDir, localAvatars, ''); - - final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, ''); - - for (final entry in localAvatars.entries) { - if (!remoteAvatars.contains(entry.key)) { - final remoteUrl = '$avatarsUrl/${entry.key}'; - final parentPath = p.dirname(entry.key); - if (parentPath != '.' && parentPath.isNotEmpty) { - await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password); - } - final success = await _uploadFile(client, remoteUrl, username, password, entry.value); - if (success) uploaded++; - } - } - - for (final relativePath in remoteAvatars) { - if (!localAvatars.containsKey(relativePath)) { - final remoteUrl = '$avatarsUrl/$relativePath'; - final localFile = File('${localAvatarsDir.path}/$relativePath'); - await localFile.parent.create(recursive: true); - final success = await _downloadFile(client, remoteUrl, username, password, localFile); - if (success) downloaded++; - } + + final zipFile = await _createAvatarsZip(); + if (await zipFile.exists()) { + final uploadSuccess = await _uploadFile(client, zipUrl, username, password, zipFile); + if (uploadSuccess) uploaded = 1; } + try { await zipFile.delete(); } catch (_) {} } catch (e) { - // 忽略错误 + // ignore } - + return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); } + /// 上传用户配置 Future _uploadUserConfig( @@ -767,43 +718,29 @@ class WebDAVService { }); } - /// 上传待处理的图片 + /// 上传待处理的图片(重新打包 zip 上传) Future _uploadPendingImages() async { final config = await getConfig(); if (config == null) return; - + try { final url = config['url']!; final username = config['username']!; final password = config['password']!; final path = config['path']!; - + final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; - final imagesUrl = '$baseUrl$path/images'; - - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${appDir.path}/images'); - + final zipUrl = '$baseUrl$path/images.zip'; + + _pendingImageUploads.clear(); + final client = http.Client(); try { - final uploads = _pendingImageUploads.toList(); - _pendingImageUploads.clear(); - - for (final localPath in uploads) { - final file = File(localPath); - if (await file.exists()) { - final relativePath = p.relative(localPath, from: imagesDir.path); - final remoteUrl = '$imagesUrl/$relativePath'; - - // 确保父目录存在 - final parentPath = p.dirname(relativePath); - if (parentPath != '.' && parentPath.isNotEmpty) { - await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password); - } - - await _uploadFile(client, remoteUrl, username, password, file); - } + final zipFile = await _createImagesZip(); + if (await zipFile.exists()) { + await _uploadFile(client, zipUrl, username, password, zipFile); } + try { await zipFile.delete(); } catch (_) {} } finally { client.close(); } @@ -900,8 +837,89 @@ class WebDAVService { - /// 同步图片(支持新的目录结构) - /// 同步 images/movies/{id}/、images/books/{id}/、images/notes/{id}/ 下的所有图片 + /// 将本地 images 目录打包为 zip 文件,返回临时文件 + Future _createImagesZip() async { + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory(p.join(appDir.path, 'images')); + final tempDir = await getTemporaryDirectory(); + final zipFile = File(p.join(tempDir.path, 'images.zip')); + + final archive = Archive(); + if (await imagesDir.exists()) { + await for (final entity in imagesDir.list(recursive: true)) { + if (entity is File) { + final bytes = await entity.readAsBytes(); + final relativePath = p.relative(entity.path, from: imagesDir.path); + archive.addFile(ArchiveFile(relativePath, bytes.length, bytes)); + } + } + } + + final zipBytes = ZipEncoder().encode(archive)!; + await zipFile.writeAsBytes(zipBytes); + return zipFile; + } + + /// 解压 images.zip 到本地 images 目录(合并模式) + Future _extractImagesZip(File zipFile) async { + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory(p.join(appDir.path, 'images')); + + final inputStream = InputFileStream(zipFile.path); + final archive = ZipDecoder().decodeBuffer(inputStream); + await inputStream.close(); + + for (final file in archive) { + if (file.isFile) { + final targetFile = File(p.join(imagesDir.path, file.name)); + await targetFile.parent.create(recursive: true); + await targetFile.writeAsBytes(file.content!); + } + } + } + + /// 将本地 avatars 目录打包为 zip 文件 + Future _createAvatarsZip() async { + final appDir = await getApplicationDocumentsDirectory(); + final avatarsDir = Directory(p.join(appDir.path, 'avatars')); + final tempDir = await getTemporaryDirectory(); + final zipFile = File(p.join(tempDir.path, 'avatars.zip')); + + final archive = Archive(); + if (await avatarsDir.exists()) { + await for (final entity in avatarsDir.list(recursive: true)) { + if (entity is File) { + final bytes = await entity.readAsBytes(); + final relativePath = p.relative(entity.path, from: avatarsDir.path); + archive.addFile(ArchiveFile(relativePath, bytes.length, bytes)); + } + } + } + + final zipBytes = ZipEncoder().encode(archive)!; + await zipFile.writeAsBytes(zipBytes); + return zipFile; + } + + /// 解压 avatars.zip 到本地 avatars 目录(合并模式) + Future _extractAvatarsZip(File zipFile) async { + final appDir = await getApplicationDocumentsDirectory(); + final avatarsDir = Directory(p.join(appDir.path, 'avatars')); + + final inputStream = InputFileStream(zipFile.path); + final archive = ZipDecoder().decodeBuffer(inputStream); + await inputStream.close(); + + for (final file in archive) { + if (file.isFile) { + final targetFile = File(p.join(avatarsDir.path, file.name)); + await targetFile.parent.create(recursive: true); + await targetFile.writeAsBytes(file.content!); + } + } + } + + /// 同步图片(zip 打包传输,一次请求完成) Future<_ImageSyncResult> _syncImages( http.Client client, String imagesUrl, @@ -911,248 +929,42 @@ class WebDAVService { ) async { int uploaded = 0; int downloaded = 0; - + try { - // 获取本地图片目录 - final appDir = await getApplicationDocumentsDirectory(); - final localImagesDir = Directory('${appDir.path}/images'); - - if (!await localImagesDir.exists()) { - await localImagesDir.create(recursive: true); - } - - // 递归获取本地所有图片文件(包含子目录) - final localImages = {}; // 相对路径 -> 文件 - await _collectLocalImages(localImagesDir, localImages, ''); - - // print('WebDAV: Local images: ${localImages.length}'); - - // 递归获取远程所有图片文件 - final remoteImages = await _listRemoteImagesRecursive(client, imagesUrl, username, password, ''); - // print('WebDAV: Remote images: ${remoteImages.length}'); - + final zipUrl = '${imagesUrl.substring(0, imagesUrl.lastIndexOf('/'))}/images.zip'; + if (direction == SyncDirection.upload) { - // 仅上传:上传所有本地图片 - // print('WebDAV: Starting upload of ${localImages.length} images...'); - for (final entry in localImages.entries) { - final relativePath = entry.key; - final remoteUrl = '$imagesUrl/$relativePath'; - - // print('WebDAV: Uploading $relativePath...'); - - // 确保远程父目录存在 - final parentPath = p.dirname(relativePath); - if (parentPath != '.' && parentPath.isNotEmpty) { - final parentUrl = '$imagesUrl/$parentPath'; - await _ensureRemoteDir(client, parentUrl, username, password); - } - - final success = await _uploadFile(client, remoteUrl, username, password, entry.value); - if (success) { - uploaded++; - // print('WebDAV: Uploaded $relativePath ($uploaded/${localImages.length})'); - } + final zipFile = await _createImagesZip(); + if (await zipFile.exists()) { + final success = await _uploadFile(client, zipUrl, username, password, zipFile); + if (success) uploaded = 1; } - // print('WebDAV: Upload complete - $uploaded/${localImages.length} images uploaded'); + try { await zipFile.delete(); } catch (_) {} } else if (direction == SyncDirection.download) { - // 仅下载:下载所有远程图片 - // print('WebDAV: Starting download of ${remoteImages.length} images...'); - for (final relativePath in remoteImages) { - final remoteUrl = '$imagesUrl/$relativePath'; - final localFile = File('${localImagesDir.path}/$relativePath'); - - // print('WebDAV: Downloading $relativePath...'); - - // 确保父目录存在 - await localFile.parent.create(recursive: true); - final success = await _downloadFile(client, remoteUrl, username, password, localFile); - if (success) { - downloaded++; - // print('WebDAV: Downloaded $relativePath ($downloaded/${remoteImages.length})'); - } + final tempDir = await getTemporaryDirectory(); + final tempZip = File(p.join(tempDir.path, 'images_dl.zip')); + final success = await _downloadFile(client, zipUrl, username, password, tempZip); + if (success) { + await _extractImagesZip(tempZip); + downloaded = 1; } - // print('WebDAV: Download complete - $downloaded/${remoteImages.length} images downloaded'); + try { await tempZip.delete(); } catch (_) {} } } catch (e) { - // print('WebDAV: Sync images error: $e'); + // ignore } - + return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); } + - /// 递归收集本地图片文件 - Future _collectLocalImages(Directory dir, Map result, String relativePath) async { - await for (final entity in dir.list()) { - if (entity is File) { - final fileName = p.basename(entity.path); - final path = relativePath.isEmpty ? fileName : '$relativePath/$fileName'; - result[path] = entity; - } else if (entity is Directory) { - final dirName = p.basename(entity.path); - final newRelativePath = relativePath.isEmpty ? dirName : '$relativePath/$dirName'; - await _collectLocalImages(entity, result, newRelativePath); - } - } - } + - /// 获取远程图片列表(递归获取所有子目录中的图片) - Future> _listRemoteImagesRecursive( - http.Client client, - String imagesUrl, - String username, - String password, - String relativePath, - ) async { - final images = []; - final currentUrl = relativePath.isEmpty ? imagesUrl : '$imagesUrl/$relativePath'; - - try { - // 创建图片目录(如果不存在) - final mkcolRequest = http.Request('MKCOL', Uri.parse(currentUrl)); - mkcolRequest.headers['Authorization'] = _basicAuth(username, password); - await client.send(mkcolRequest); - - // 列出目录内容 - var request = http.Request('PROPFIND', Uri.parse(currentUrl)); - request.headers['Authorization'] = _basicAuth(username, password); - request.headers['Depth'] = '1'; - - var response = await client.send(request); - - // 处理重定向 - if (response.statusCode == 301 || response.statusCode == 302 || - response.statusCode == 307 || response.statusCode == 308) { - final location = response.headers['location']; - if (location != null) { - final newUrl = location; - request = http.Request('PROPFIND', Uri.parse(newUrl)); - request.headers['Authorization'] = _basicAuth(username, password); - request.headers['Depth'] = '1'; - response = await client.send(request); - } - } - - if (response.statusCode == 207) { - final body = await response.stream.bytesToString(); - // print('WebDAV: PROPFIND response for $relativePath: ${body.length} bytes'); - // print('WebDAV: Response body: $body'); - - // 解析响应,提取文件和目录 - final hrefMatches = RegExp(r'([^<]+)', caseSensitive: false) - .allMatches(body); - - // print('WebDAV: Found ${hrefMatches.length} href entries'); - - for (final match in hrefMatches) { - final href = match.group(1)!; - final name = p.basename(href); - - // 跳过当前目录自身(WebDAV PROPFIND 结果中第一个或某个 entry 是当前目录) - if (name.isEmpty) continue; - final currentUrlPath = Uri.parse(currentUrl).path; - final currentDirName = p.basename(currentUrlPath); - if (name == currentDirName) continue; - - // 检查是文件还是目录 - 查找这个 href 对应的 部分 - // 使用正则匹配,因为标签可能有属性(如 ) - int responseStart = -1; - int responseEnd = -1; - - // 查找包含当前 href 的 response 块(向前找最近的 response 开始标签) - final responseStartPattern = RegExp(r'<[Dd]:response\b', caseSensitive: false); - final responseEndPattern = RegExp(r'', caseSensitive: false); - - // 从 match.start 向前找最后一个 response 开始标签 - final allStarts = responseStartPattern.allMatches(body.substring(0, match.start)).toList(); - if (allStarts.isNotEmpty) { - responseStart = allStarts.last.start; - } - - // 从 match.start 向后找第一个 response 结束标签 - final endMatch = responseEndPattern.firstMatch(body.substring(match.start)); - if (endMatch != null) { - responseEnd = match.start + endMatch.end; - } - - bool isDirectory = false; - - if (responseStart != -1 && responseEnd != -1 && responseStart < responseEnd) { - final responseSection = body.substring(responseStart, responseEnd); - // 检查是否包含 标签 - isDirectory = responseSection.contains('') || - responseSection.contains('') || - responseSection.contains('') || - responseSection.contains(''); - } - - // print('WebDAV: Found $name - isDirectory: $isDirectory'); - - if (isDirectory) { - // 递归获取子目录中的图片 - final newRelativePath = relativePath.isEmpty ? name : '$relativePath/$name'; - final subImages = await _listRemoteImagesRecursive( - client, imagesUrl, username, password, newRelativePath, - ); - images.addAll(subImages); - } else { - // 是文件,添加到列表 - final filePath = relativePath.isEmpty ? name : '$relativePath/$name'; - // print('WebDAV: Adding file to list: $filePath'); - images.add(filePath); - } - } - } else { - // print('WebDAV: PROPFIND failed with status ${response.statusCode} for $relativePath'); - } - } catch (e) { - // print('WebDAV: List remote images error: $e'); - } - - return images; - } + - /// 确保远程目录存在 - Future _ensureRemoteDir( - http.Client client, - String dirUrl, - String username, - String password, - ) async { - try { - var request = http.Request('MKCOL', Uri.parse(dirUrl)); - request.headers['Authorization'] = _basicAuth(username, password); - - var response = await client.send(request); - - // 处理重定向 - if (response.statusCode == 301 || response.statusCode == 302 || - response.statusCode == 307 || response.statusCode == 308) { - final location = response.headers['location']; - if (location != null) { - request = http.Request('MKCOL', Uri.parse(location)); - request.headers['Authorization'] = _basicAuth(username, password); - response = await client.send(request); - } - } - - // 201 = 创建成功, 405 = 目录已存在, 409 = 父目录不存在需要先创建 - if (response.statusCode == 409) { - // 需要创建父目录 - final parentPath = p.dirname(dirUrl); - if (parentPath != dirUrl) { - await _ensureRemoteDir(client, parentPath, username, password); - // 再次尝试创建当前目录 - request = http.Request('MKCOL', Uri.parse(dirUrl)); - request.headers['Authorization'] = _basicAuth(username, password); - await client.send(request); - } - } - } catch (e) { - // print('WebDAV: 创建目录失败: $e'); - } - } + - /// 上传文件 + /// 上传文件(数据库文件上传前自动 VACUUM 压缩) Future _uploadFile( http.Client client, String url, @@ -1161,6 +973,14 @@ class WebDAVService { File file, ) async { try { + // 数据库文件:上传前 VACUUM 压缩 + if (p.basename(url).endsWith('.db')) { + try { + final db = await DatabaseHelper.instance.database; + await db.execute('VACUUM'); + } catch (_) {} + } + final fileBytes = await file.readAsBytes(); var request = http.Request('PUT', Uri.parse(url)); diff --git a/lib/utils/tag/tag_dao.dart b/lib/utils/tag/tag_dao.dart new file mode 100644 index 0000000..8257355 --- /dev/null +++ b/lib/utils/tag/tag_dao.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; +import '../database_helper.dart'; + +class TagDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + /// 获取指定类型的所有标签,按名称排序 + Future>> getTagsByType(String type) async { + final db = await _dbHelper.database; + return await db.query('tags', + where: 'type = ?', + whereArgs: [type], + orderBy: 'name ASC'); + } + + /// 根据ID获取标签 + Future?> getTagById(String id) async { + final db = await _dbHelper.database; + final results = await db.query('tags', where: 'id = ?', whereArgs: [id]); + return results.isNotEmpty ? results.first : null; + } + + /// 添加标签,返回新标签ID + Future addTag(String name, String type) async { + final db = await _dbHelper.database; + final id = 'tag_${DateTime.now().millisecondsSinceEpoch}'; + await db.insert('tags', { + 'id': id, + 'name': name, + 'type': type, + 'created_at': DateTime.now().toIso8601String(), + }); + return id; + } + + /// 重命名标签,同时级联更新所有关联条目 + Future renameTag(String tagId, String newName) async { + final db = await _dbHelper.database; + + final tag = await getTagById(tagId); + if (tag == null) return false; + + final oldName = tag['name'] as String; + final type = tag['type'] as String; + if (oldName == newName) return true; + + // 检查新名称是否已存在同类型标签 + final existing = await db.query('tags', + where: 'name = ? AND type = ? AND id != ?', + whereArgs: [newName, type, tagId]); + if (existing.isNotEmpty) return false; + + await db.update('tags', {'name': newName}, + where: 'id = ?', whereArgs: [tagId]); + + // 级联更新 + switch (type) { + case 'movie_genre': + await _cascadeRenameInMovies(oldName, newName); + case 'book_genre': + await _cascadeRenameInBooks(oldName, newName); + case 'note_tag': + await _cascadeRenameInNotes(oldName, newName); + } + + return true; + } + + /// 删除标签 + /// [replacementName] 不为 null 时,先将所有条目中的旧标签替换为新标签,再删除 + /// [replacementName] 为 null 时,从所有条目中移除该标签 + Future deleteTag(String tagId, {String? replacementName}) async { + final db = await _dbHelper.database; + + final tag = await getTagById(tagId); + if (tag == null) return; + + final name = tag['name'] as String; + final type = tag['type'] as String; + + if (replacementName != null && replacementName != name) { + await _ensureTagExists(replacementName, type); + switch (type) { + case 'movie_genre': + await _cascadeRenameInMovies(name, replacementName); + case 'book_genre': + await _cascadeRenameInBooks(name, replacementName); + case 'note_tag': + await _cascadeRenameInNotes(name, replacementName); + } + } else if (replacementName == null) { + switch (type) { + case 'movie_genre': + await _cascadeDeleteFromMovies(name); + case 'book_genre': + await _cascadeDeleteFromBooks(name); + case 'note_tag': + await _cascadeDeleteFromNotes(name); + } + } + + await db.delete('tags', where: 'id = ?', whereArgs: [tagId]); + } + + /// 确保标签存在(用于替换操作) + Future _ensureTagExists(String name, String type) async { + final db = await _dbHelper.database; + final existing = await db.query('tags', + where: 'name = ? AND type = ?', whereArgs: [name, type]); + if (existing.isEmpty) { + await addTag(name, type); + } + } + + // ====== 级联重命名 ====== + + Future _cascadeRenameInMovies(String oldName, String newName) async { + final db = await _dbHelper.database; + final movies = await db.query('movies'); + for (final row in movies) { + final genres = _parseList(row['genres']); + if (genres.contains(oldName) && !genres.contains(newName)) { + final updated = genres.map((g) => g == oldName ? newName : g).toList(); + await db.update('movies', { + 'genres': jsonEncode(updated), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + Future _cascadeRenameInBooks(String oldName, String newName) async { + final db = await _dbHelper.database; + final books = await db.query('books'); + for (final row in books) { + final genres = _parseList(row['genres']); + if (genres.contains(oldName) && !genres.contains(newName)) { + final updated = genres.map((g) => g == oldName ? newName : g).toList(); + await db.update('books', { + 'genres': jsonEncode(updated), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + Future _cascadeRenameInNotes(String oldName, String newName) async { + final db = await _dbHelper.database; + final notes = await db.query('notes'); + for (final row in notes) { + final tags = _parseList(row['tags']); + if (tags.contains(oldName) && !tags.contains(newName)) { + final updated = tags.map((t) => t == oldName ? newName : t).toList(); + await db.update('notes', { + 'tags': jsonEncode(updated), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + // ====== 级联删除 ====== + + Future _cascadeDeleteFromMovies(String tagName) async { + final db = await _dbHelper.database; + final movies = await db.query('movies'); + for (final row in movies) { + final genres = _parseList(row['genres']); + if (genres.contains(tagName)) { + genres.removeWhere((g) => g == tagName); + await db.update('movies', { + 'genres': jsonEncode(genres), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + Future _cascadeDeleteFromBooks(String tagName) async { + final db = await _dbHelper.database; + final books = await db.query('books'); + for (final row in books) { + final genres = _parseList(row['genres']); + if (genres.contains(tagName)) { + genres.removeWhere((g) => g == tagName); + await db.update('books', { + 'genres': jsonEncode(genres), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + Future _cascadeDeleteFromNotes(String tagName) async { + final db = await _dbHelper.database; + final notes = await db.query('notes'); + for (final row in notes) { + final tags = _parseList(row['tags']); + if (tags.contains(tagName)) { + tags.removeWhere((t) => t == tagName); + await db.update('notes', { + 'tags': jsonEncode(tags), + 'updated_at': DateTime.now().toIso8601String(), + }, where: 'id = ?', whereArgs: [row['id']]); + } + } + } + + /// 解析 JSON 字符串列表 + List _parseList(dynamic data) { + if (data == null) return []; + if (data is List) return data.map((e) => e.toString()).toList(); + if (data is String) { + if (data.isEmpty || data == '[]') return []; + try { + final decoded = jsonDecode(data); + if (decoded is List) { + return decoded.map((e) => e.toString()).toList(); + } + } catch (_) { + return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + } + } + return []; + } +} diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 913541e..b3f124a 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -8,6 +8,7 @@ import '../utils/user_prefs.dart'; import '../models/data_models.dart'; import '../pages/stroll_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; +import '../pages/tag_management_page.dart'; /// 自定义左侧弹出菜单 - 极简主义设计 class CustomDrawer extends StatefulWidget { @@ -272,8 +273,7 @@ class _CustomDrawerState extends State { MaterialPageRoute(builder: (_) => const MdReaderTabPage()), ); }, - borderRadius: - const BorderRadius.vertical(bottom: Radius.circular(10)), + borderRadius: BorderRadius.zero, child: const Padding( padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), child: Row( @@ -301,6 +301,49 @@ class _CustomDrawerState extends State { ), ), ), + + // 分隔线 + const Divider( + height: 0.5, thickness: 0.5, color: Color(0xFFEEEEEE)), + + // 标签管理 + InkWell( + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const TagManagementPage()), + ); + }, + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(10)), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), + child: Row( + children: [ + Icon(Icons.label_outline, + size: 18, color: Color(0xFF666666)), + SizedBox(width: 10), + Expanded( + child: Text( + '标签管理', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A)), + ), + ), + Text( + '管理标签', + style: TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), + ), + SizedBox(width: 6), + Icon(Icons.chevron_right, + size: 16, color: Color(0xFFCCCCCC)), + ], + ), + ), + ), ], ), ), diff --git a/lib/widgets/markdown_editing_controller.dart b/lib/widgets/markdown_editing_controller.dart deleted file mode 100644 index 8e90be8..0000000 --- a/lib/widgets/markdown_editing_controller.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Markdown 编辑器控制器 -/// 实现 Typora 风格的所见即所得 Markdown 编辑体验 -/// 输入 # 标题 时,# 变小变淡,标题文字变大加粗 -/// 输入 **粗体** 时,文字自动加粗 -class MarkdownEditingController extends TextEditingController { - MarkdownEditingController({String? text}) : super(text: text); - @override - TextSpan buildTextSpan({ - required BuildContext context, - TextStyle? style, - required bool withComposing, - }) { - return _buildMarkdownSpan(text, style); - } - - /// 构建 Markdown 样式的 TextSpan - TextSpan _buildMarkdownSpan(String text, TextStyle? baseStyle) { - if (text.isEmpty) { - return TextSpan(text: '', style: baseStyle); - } - - final spans = []; - final lines = text.split('\n'); - - for (var i = 0; i < lines.length; i++) { - if (i > 0) { - spans.add(const TextSpan(text: '\n')); - } - spans.add(_parseLine(lines[i], baseStyle)); - } - - return TextSpan(children: spans); - } - - /// 解析单行文本 - InlineSpan _parseLine(String line, TextStyle? baseStyle) { - // 空行 - if (line.isEmpty) { - return const TextSpan(text: ''); - } - - // 代码块分隔符 ``` - if (line.startsWith('```')) { - return TextSpan( - text: line, - style: _codeBlockStyle(baseStyle), - ); - } - - // 标题 # ## ### 等 - if (line.startsWith('#')) { - final headerMatch = RegExp(r'^(#{1,6})\s+(.*)$').firstMatch(line); - if (headerMatch != null) { - final level = headerMatch.group(1)!.length; - final content = headerMatch.group(2)!; - return _buildHeaderSpan(level, content, baseStyle); - } - } - - // 引用 > - if (line.startsWith('>')) { - final quoteMatch = RegExp(r'^>\s?(.*)$').firstMatch(line); - if (quoteMatch != null) { - final content = quoteMatch.group(1)!; - return _buildQuoteSpan(content, baseStyle); - } - } - - // 无序列表 - 或 * - final ulMatch = RegExp(r'^([\-\*])\s+(.*)$').firstMatch(line); - if (ulMatch != null) { - final content = ulMatch.group(2)!; - return _buildListSpan(content, baseStyle, isOrdered: false); - } - - // 有序列表 1. 2. 等 - final olMatch = RegExp(r'^(\d+)\.\s+(.*)$').firstMatch(line); - if (olMatch != null) { - final number = olMatch.group(1)!; - final content = olMatch.group(2)!; - return _buildListSpan(content, baseStyle, isOrdered: true, number: number); - } - - // 分割线 --- *** ___ - if (RegExp(r'^( {0,3}([-_*])\s*\2\s*\2[\s\2]*)$').hasMatch(line)) { - return _buildDividerSpan(line, baseStyle); - } - - // 普通行 - 解析行内元素 - return _parseInline(line, baseStyle); - } - - // ==================== 标题 ==================== - - InlineSpan _buildHeaderSpan(int level, String content, TextStyle? baseStyle) { - // 标题只改变颜色和粗细,不改变字体大小,避免光标错位 - final headerStyle = (baseStyle ?? const TextStyle()).copyWith( - fontWeight: FontWeight.w600, - color: const Color(0xFF1A1A1A), - ); - - return TextSpan( - children: [ - TextSpan( - text: '${'#' * level} ', - style: const TextStyle( - color: Color(0xFFCCCCCC), - fontWeight: FontWeight.w400, - ), - ), - ..._parseInlineSpans(content, headerStyle), - ], - ); - } - - // ==================== 引用 ==================== - - InlineSpan _buildQuoteSpan(String content, TextStyle? baseStyle) { - final quoteStyle = (baseStyle ?? const TextStyle()).copyWith( - color: const Color(0xFF666666), - fontStyle: FontStyle.italic, - height: 1.8, - ); - - return TextSpan( - children: [ - const TextSpan( - text: '> ', - style: TextStyle( - color: Color(0xFF999999), - fontWeight: FontWeight.bold, - ), - ), - ..._parseInlineSpans(content, quoteStyle), - ], - ); - } - - // ==================== 列表 ==================== - - InlineSpan _buildListSpan(String content, TextStyle? baseStyle, - {required bool isOrdered, String? number}) { - return TextSpan( - children: [ - TextSpan( - text: isOrdered ? '$number. ' : '• ', - style: const TextStyle( - color: Color(0xFF333333), - fontWeight: FontWeight.w600, - ), - ), - ..._parseInlineSpans( - content, - (baseStyle ?? const TextStyle()).copyWith( - color: const Color(0xFF333333), - ), - ), - ], - ); - } - - // ==================== 分割线 ==================== - - InlineSpan _buildDividerSpan(String line, TextStyle? baseStyle) { - // 返回原始文本,但用灰色显示 - return TextSpan( - text: line, - style: const TextStyle( - color: Color(0xFFCCCCCC), - ), - ); - } - - // ==================== 行内元素解析 ==================== - - InlineSpan _parseInline(String text, TextStyle? baseStyle) { - return TextSpan(children: _parseInlineSpans(text, baseStyle)); - } - - /// 解析行内 Markdown 元素 - /// 返回 InlineSpan 列表 - List _parseInlineSpans(String text, TextStyle? baseStyle) { - if (text.isEmpty) { - return [const TextSpan(text: '')]; - } - - // 收集所有匹配的模式 - final patterns = <_MatchPattern>[]; - - // 粗体 **text** - for (final match in RegExp(r'\*\*([^*]+)\*\*').allMatches(text)) { - if (match.group(1)!.isNotEmpty) { - patterns.add(_MatchPattern( - match.start, - match.end, - _InlineType.bold, - match.group(0)!, - match.group(1)!, - )); - } - } - - // 斜体 *text* (排除 **) - for (final match in RegExp(r'(? a.start.compareTo(b.start)); - - // 过滤重叠的模式(选择第一个匹配的,跳过被包含的) - final filtered = <_MatchPattern>[]; - _MatchPattern? last; - for (final pattern in patterns) { - if (last == null || pattern.start >= last.end) { - filtered.add(pattern); - last = pattern; - } - } - - // 构建 InlineSpan 列表 - final spans = []; - var currentPos = 0; - - for (final pattern in filtered) { - // 添加匹配前的普通文本 - if (pattern.start > currentPos) { - spans.add(TextSpan( - text: text.substring(currentPos, pattern.start), - style: baseStyle, - )); - } - - // 添加带样式的匹配内容 - final style = _getInlineStyle(pattern.type, baseStyle); - spans.add(TextSpan( - text: pattern.content, - style: style, - )); - - currentPos = pattern.end; - } - - // 添加剩余的普通文本 - if (currentPos < text.length) { - spans.add(TextSpan( - text: text.substring(currentPos), - style: baseStyle, - )); - } - - return spans; - } - - /// 获取行内元素的样式 - TextStyle? _getInlineStyle(_InlineType type, TextStyle? base) { - final baseStyle = base ?? const TextStyle(); - switch (type) { - case _InlineType.bold: - return baseStyle.copyWith(fontWeight: FontWeight.bold); - case _InlineType.italic: - return baseStyle.copyWith(fontStyle: FontStyle.italic); - case _InlineType.strikethrough: - return baseStyle.copyWith( - decoration: TextDecoration.lineThrough, - color: const Color(0xFF999999), - ); - case _InlineType.inlineCode: - return baseStyle.copyWith( - fontFamily: 'monospace', - backgroundColor: const Color(0xFFF5F5F5), - color: const Color(0xFF1A1A1A), - ); - case _InlineType.link: - return baseStyle.copyWith( - color: const Color(0xFF4A90D9), - decoration: TextDecoration.underline, - ); - } - } - - /// 代码块样式 - TextStyle _codeBlockStyle(TextStyle? baseStyle) { - return (baseStyle ?? const TextStyle()).copyWith( - fontFamily: 'monospace', - color: const Color(0xFF999999), - fontSize: 14, - ); - } -} - -/// 行内元素类型 -enum _InlineType { - bold, - italic, - strikethrough, - inlineCode, - link, -} - -/// 匹配模式 -class _MatchPattern { - final int start; - final int end; - final _InlineType type; - final String fullMatch; - final String content; - final String? url; - - _MatchPattern( - this.start, - this.end, - this.type, - this.fullMatch, - this.content, { - this.url, - }); -}