diff --git a/CLAUDE2.md b/CLAUDE2.md deleted file mode 100644 index 8e59037..0000000 --- a/CLAUDE2.md +++ /dev/null @@ -1,79 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -MookNote is a Flutter-based Android app for tracking movies, books, and notes. Language: Dart/Flutter with Chinese UI. AGPL-3.0 licensed. - -## Common Commands - -```bash -# Install dependencies -flutter pub get - -# Run the app (connect Android device or start emulator first) -flutter run - -# Build release APK -flutter build apk --release - -# Build App Bundle (for Google Play) -flutter build appbundle --release - -# Clean build -flutter clean && flutter pub get - -# Static analysis -flutter analyze - -# Use China mirror if pub get fails -$env:PUB_HOSTED_URL="https://pub.flutter-io.cn" -$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn" -``` - -## Architecture - -### App Structure (lib/) - -- **main.dart** — App entry; initializes `UserPrefs`, `AppProvider`, auto-backup, usage stats, and sync validation on startup -- **models/data_models.dart** — All data models in one file: `Movie`, `Book`, `Note`, `MovieReview`, `BookReview`, `MoviePoster`, `BookExcerpt`. Each has `fromJson`/`toJson`/`copyWith` -- **providers/app_provider.dart** — Single `AppProvider` (ChangeNotifier) holds all app state. Manages movies, books, notes lists, theme mode, tab indices, drawer state. Uses DAO pattern for data access. Has a `_useRemote` flag to switch between local SQLite and remote server -- **pages/** — UI pages organized by feature domain: `movies/`, `book/`, `note/`, `sync/`, `markdown_reader/` -- **utils/** — Business logic layer: - - `database_helper.dart` — SQLite database (sqflite), version 13, with migration chain - - `movie/`, `book/`, `note/` — DAO classes for each entity (CRUD operations) - - `tag/tag_dao.dart` — Tag management - - `sync/` — Server sync, WebDAV sync, auto-backup, backup service - - `theme/app_theme.dart` — Minimalist black/white/gray theme with Material 3 - - `user_prefs.dart` — SharedPreferences wrapper (singleton) -- **widgets/** — Shared reusable widgets (list items, star rating, drawer, bottom nav, shimmer skeleton) -- **utils/app_router.dart** — Named route generator using `onGenerateRoute` with `SlideUpPageRoute` transitions - -### State Management - -Provider pattern. A single `AppProvider` (ChangeNotifier) is provided at the root via `MultiProvider`/`Consumer`. All pages read state from `context.watch()` or `context.read()`. - -### Data Layer - -- **Local**: SQLite via sqflite (`DatabaseHelper` singleton, DB name: `mooknote.db`) -- **Remote**: Optional server sync via `ServerSyncService` / `ServerDataService`. Controlled by `UserPrefs.syncEnabled` + activation code -- **DAO pattern**: Each entity has its own DAO (`MovieDao`, `BookDao`, `NoteDao`, etc.) that can operate locally or remotely based on `_useRemote` flag in AppProvider -- **Soft delete**: All models have `isDeleted` field; a recycle bin page manages restores - -### Image Storage - -Images stored at: `/mooknote/images///` on device. `ImagePathHelper` manages paths. `FadeInLocalImage` widget handles local image display. - -### Server (server/) - -Python Flask backend for user stats and sync API. Not part of the Flutter build. Run with `python app.py` or gunicorn. - -## Key Conventions - -- All Chinese UI strings are hardcoded (no i18n framework beyond Flutter's built-in localization delegates) -- UUID-based IDs for all entities (generated at creation time) -- List fields (directors, actors, genres, tags) stored as JSON-encoded strings in SQLite, parsed via `Movie.parseStringList()` -- `copyWith` uses a `_CopyWithNullSentinel` pattern to distinguish "not passed" from "passed null" for nullable fields -- Route transitions use custom `SlideUpPageRoute` (bottom-to-top slide) -- DB migrations in `DatabaseHelper._onUpgrade` — always add new migration blocks with version checks (`if (oldVersion < N)`) diff --git a/README.md b/README.md index 798e354..ba2bcb0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC ## 应用预览 -| 影视列表 | 书籍列表 | 笔记界面 | -| :------------------------------------------------------------: | :------------------------------------------------------------: | :------------------------------------------------------------: | -| `` | `` | `` | +| 影视列表 | 书籍列表 | 笔记界面 | +| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: | +| ![1782484732881](image/README/1782484732881.png) | ![1782484753882](image/README/1782484753882.png) | ![1782484769014](image/README/1782484769014.png) | -更多预览图片请到**应用功能详情预览**里面查看 +更多预览图片请到**应用功能****详情预览**里面查看 ## 功能特性 @@ -148,17 +148,17 @@ flutter build appbundle --release ## 应用功能详情预览 -| 数据统计 | 侧边栏 | 我的界面 | -| :-------------------------------------------------------------: | :------------------------------------------------------------: | :-------------------------------------------------------------------: | -| `` | `` | `` | +| 数据统计 | 侧边栏 | 我的界面 | +| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: | +| ![1782484806542](image/README/1782484806542.png) | ![1782484815573](image/README/1782484815573.png) | ![1782484824223](image/README/1782484824223.png) | -| 详情界面1 | 详情界面2 | 编辑笔记 | -| :------------------------------------------: | :------------------------------------------: | :------------------------------------------: | -| `` | `` | `` | +| 详情界面1 | 详情界面2 | 编辑笔记 | +| :--------------------------------------------: | :--------------------------------------------: | :---------------------------------------------: | +| ![1782484832641](image/README/1782484832641.png) | ![1782484836809](image/README/1782484836809.png) | ![1782484844066](image/README/1782484844066.png) | -| 分享界面1 | 分享界面2 | 预览笔记 | -| :------------------------------------------: | :------------------------------------------: | :------------------------------------------: | -| `` | `` | `` | +| 分享界面1 | 分享界面2 | 预览笔记 | +| :---------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | +| ![1782484860728](image/README/1782484860728.png) | ![1782484855884](image/README/1782484855884.png) | ![1782484849659](image/README/1782484849659.png) | ## 数据对接 diff --git a/assets/fonts/LXGWWenKai-Regular.ttf b/assets/fonts/LXGWWenKai-Regular.ttf new file mode 100644 index 0000000..eb61629 Binary files /dev/null and b/assets/fonts/LXGWWenKai-Regular.ttf differ diff --git a/assets/fonts/NotoSerifSC-Regular.ttf b/assets/fonts/NotoSerifSC-Regular.ttf new file mode 100644 index 0000000..d667cb5 Binary files /dev/null and b/assets/fonts/NotoSerifSC-Regular.ttf differ diff --git a/assets/fonts/OPPO_Sans4.0.ttf b/assets/fonts/OPPO_Sans4.0.ttf new file mode 100644 index 0000000..e649ce5 Binary files /dev/null and b/assets/fonts/OPPO_Sans4.0.ttf differ diff --git a/assets/fonts/SmileySans-Oblique.ttf b/assets/fonts/SmileySans-Oblique.ttf new file mode 100644 index 0000000..c297dc6 Binary files /dev/null and b/assets/fonts/SmileySans-Oblique.ttf differ diff --git a/image/README/1782484732881.png b/image/README/1782484732881.png new file mode 100644 index 0000000..812e9a7 Binary files /dev/null and b/image/README/1782484732881.png differ diff --git a/image/README/1782484753882.png b/image/README/1782484753882.png new file mode 100644 index 0000000..02bc7f4 Binary files /dev/null and b/image/README/1782484753882.png differ diff --git a/image/README/1782484769014.png b/image/README/1782484769014.png new file mode 100644 index 0000000..3446056 Binary files /dev/null and b/image/README/1782484769014.png differ diff --git a/image/README/1782484806542.png b/image/README/1782484806542.png new file mode 100644 index 0000000..be8bd99 Binary files /dev/null and b/image/README/1782484806542.png differ diff --git a/image/README/1782484815573.png b/image/README/1782484815573.png new file mode 100644 index 0000000..f8616b9 Binary files /dev/null and b/image/README/1782484815573.png differ diff --git a/image/README/1782484824223.png b/image/README/1782484824223.png new file mode 100644 index 0000000..4c851e5 Binary files /dev/null and b/image/README/1782484824223.png differ diff --git a/image/README/1782484832641.png b/image/README/1782484832641.png new file mode 100644 index 0000000..5fbc872 Binary files /dev/null and b/image/README/1782484832641.png differ diff --git a/image/README/1782484836809.png b/image/README/1782484836809.png new file mode 100644 index 0000000..0d0f631 Binary files /dev/null and b/image/README/1782484836809.png differ diff --git a/image/README/1782484844066.png b/image/README/1782484844066.png new file mode 100644 index 0000000..8d67cee Binary files /dev/null and b/image/README/1782484844066.png differ diff --git a/image/README/1782484849659.png b/image/README/1782484849659.png new file mode 100644 index 0000000..9c4b910 Binary files /dev/null and b/image/README/1782484849659.png differ diff --git a/image/README/1782484855884.png b/image/README/1782484855884.png new file mode 100644 index 0000000..f073488 Binary files /dev/null and b/image/README/1782484855884.png differ diff --git a/image/README/1782484860728.png b/image/README/1782484860728.png new file mode 100644 index 0000000..53dfbe6 Binary files /dev/null and b/image/README/1782484860728.png differ diff --git a/lib/main.dart b/lib/main.dart index b8a1c5b..188629b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:url_launcher/url_launcher.dart'; +import 'package:dynamic_color/dynamic_color.dart'; import 'pages/home_page.dart'; import 'utils/theme/app_theme.dart'; import 'utils/app_router.dart'; @@ -193,12 +194,17 @@ class _MyAppState extends State with WidgetsBindingObserver { ], child: Consumer( builder: (context, provider, _) { - return MaterialApp( - title: 'MookNote', - debugShowCheckedModeBanner: false, - theme: AppTheme.getLightTheme(provider.colorSchemeIndex), - darkTheme: AppTheme.darkTheme, - themeMode: provider.themeMode, + AppTheme.setFontFamily(provider.fontFamily); + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + final monetColor = lightDynamic?.primary; + AppTheme.setMonetColor(monetColor); + return MaterialApp( + title: 'MookNote', + debugShowCheckedModeBanner: false, + theme: AppTheme.getLightTheme(provider.colorSchemeIndex, monetColor: monetColor), + darkTheme: AppTheme.darkTheme, + themeMode: provider.themeMode, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, @@ -216,6 +222,8 @@ class _MyAppState extends State with WidgetsBindingObserver { return _AppIconWrapper(iconName: iconName, child: child!); }, ); + }, + ); }, ), ); diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index f8d439f..ad88154 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -314,6 +314,7 @@ class Note { final DateTime createdAt; final DateTime updatedAt; final bool isDeleted; + final bool isPinned; Note({ required this.id, @@ -325,6 +326,7 @@ class Note { required this.createdAt, required this.updatedAt, this.isDeleted = false, + this.isPinned = false, }); factory Note.fromJson(Map json) { @@ -342,6 +344,7 @@ class Note { ? DateTime.parse(json['updated_at']) : DateTime.now(), isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + isPinned: json['is_pinned'] == 1 || json['is_pinned'] == true, ); } @@ -356,9 +359,10 @@ class Note { 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, + 'is_pinned': isPinned ? 1 : 0, }; } - + /// 复制并修改 Note copyWith({ String? id, @@ -370,6 +374,7 @@ class Note { DateTime? createdAt, DateTime? updatedAt, bool? isDeleted, + bool? isPinned, }) { return Note( id: id ?? this.id, @@ -381,6 +386,7 @@ class Note { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, + isPinned: isPinned ?? this.isPinned, ); } diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 5360fbd..0469c65 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -31,11 +31,15 @@ class _BookDetailPageState extends State { final ValueNotifier _draggingCover = ValueNotifier(false); final GlobalKey _coverImageKey = GlobalKey(); double _coverImageHeight = 0.0; + final ValueNotifier _showTitle = ValueNotifier(false); + ScrollController? _overlayScrollController; @override void dispose() { _coverOffset.dispose(); _draggingCover.dispose(); + _showTitle.dispose(); + _overlayScrollController?.dispose(); super.dispose(); } @@ -66,25 +70,20 @@ class _BookDetailPageState extends State { backgroundColor: colors.surface, body: Stack( children: [ - // 图片从导航栏下方开始,固定在顶部 - Column( - children: [ - SizedBox(height: topSafe + 48), - SizedBox( - height: 320, - width: double.infinity, - child: _buildCoverSection(book), - ), - ], - ), - // 可滚动内容区域从图片底部开始 - Positioned( - top: topSafe + 48 + 320, - left: 0, right: 0, bottom: 0, + // 整体可滚动(封面 + 内容一起滑动) + Padding( + padding: EdgeInsets.only(top: topSafe + 48), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 封面图 + SizedBox( + height: 320, + width: double.infinity, + child: _buildCoverSection(book), + ), + // 详细信息 _buildBasicInfo(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), _buildAuthorsSection(book), @@ -113,6 +112,11 @@ class _BookDetailPageState extends State { final screenH = MediaQuery.of(context).size.height; final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + _overlayScrollController ??= ScrollController()..addListener(() { + final show = (_overlayScrollController?.offset ?? 0) > 10; + if (_showTitle.value != show) _showTitle.value = show; + }); + return Scaffold( body: Stack( children: [ @@ -142,7 +146,7 @@ class _BookDetailPageState extends State { SafeArea( child: Column( children: [ - // 顶部栏 + // 顶部栏:只在滚动后显示标题 SizedBox( height: 48, child: Row(children: [ @@ -151,9 +155,19 @@ class _BookDetailPageState extends State { icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), onPressed: () => Navigator.pop(context), ), - const Spacer(), - Text(book.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), - maxLines: 1, overflow: TextOverflow.ellipsis), + ValueListenableBuilder( + valueListenable: _showTitle, + builder: (_, show, __) => AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), + child: Text(book.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + ), const Spacer(), _buildStyleButton(), ]), @@ -161,6 +175,7 @@ class _BookDetailPageState extends State { // 可滚动内容 Expanded( child: SingleChildScrollView( + controller: _overlayScrollController, padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -485,33 +500,34 @@ class _BookDetailPageState extends State { void _showStylePicker() { final colors = Theme.of(context).colorScheme; const names = ['默认样式', '毛玻璃层叠']; + const icons = [Icons.article_outlined, Icons.blur_on_outlined]; + const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片']; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), - const SizedBox(height: 16), - Wrap(spacing: 10, runSpacing: 10, children: List.generate(names.length, (i) { - final selected = _detailStyle == i; - return GestureDetector( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 12), + for (int i = 0; i < names.length; i++) ...[ + if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), + subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: _detailStyle == i + ? Icon(Icons.check_circle, size: 20, color: colors.primary) + : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5), - ), - child: Text(names[i], style: TextStyle( - fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6), - )), - ), - ); - })), + ), + ], + const SizedBox(height: 12), ]), ), ); diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 1d7a22e..3f24edf 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -1,7 +1,8 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; @@ -11,11 +12,19 @@ import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../widgets/genre_selector_page.dart'; +import '../../widgets/text_input_panel.dart'; -/// 添加/编辑书籍页面 - 紧凑双行布局设计 +/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用) +List _collectUnique(List> lists) { + final s = {}; + for (final l in lists) { s.addAll(l); } + return s.toList()..sort(); +} + +/// 添加/编辑书籍页面 class BookFormPage extends StatefulWidget { final Book? book; - final String? initialStatus; // 添加时的默认状态 + final String? initialStatus; const BookFormPage({super.key, this.book, this.initialStatus}); @@ -27,23 +36,19 @@ class _BookFormPageState extends State { final _formKey = GlobalKey(); final ImagePicker _picker = ImagePicker(); - // 输入框控制器 late TextEditingController _titleController; late TextEditingController _publisherController; late TextEditingController _summaryController; late TextEditingController _ratingController; late TextEditingController _isbnController; - // 多值字段的临时输入控制器 - final Map _tagControllers = {}; - - // 数据 List _authors = []; List _alternateTitles = []; List _genres = []; String? _coverPath; String _status = 'want_to_read'; DateTime? _publishDate; + bool _isDownloading = false; @override void initState() { @@ -52,16 +57,11 @@ class _BookFormPageState extends State { } void _initializeData() { - // 如果有传入book,尝试从Provider获取最新数据 Book? book = widget.book; if (book != null) { final appProvider = context.read(); - final latestBook = appProvider.books - .where((b) => b.id == book!.id) - .firstOrNull; - if (latestBook != null) { - book = latestBook; - } + final latestBook = appProvider.books.where((b) => b.id == book!.id).firstOrNull; + if (latestBook != null) book = latestBook; } _titleController = TextEditingController(text: book?.title ?? ''); @@ -78,7 +78,6 @@ class _BookFormPageState extends State { _status = book.status; _publishDate = book.publishDate; } else if (widget.initialStatus != null) { - // 添加模式:使用传入的默认状态 _status = widget.initialStatus!; } } @@ -90,42 +89,20 @@ class _BookFormPageState extends State { _summaryController.dispose(); _ratingController.dispose(); _isbnController.dispose(); - _tagControllers.values.forEach((c) => c.dispose()); super.dispose(); } - TextEditingController _getTagController(String key) { - return _tagControllers.putIfAbsent(key, () => TextEditingController()); - } - - /// 构建右上角操作按钮 - Widget _buildActionButton({ - required IconData icon, - required VoidCallback onPressed, - required String tooltip, - Color? color, - }) { + Widget _buildActionButton({required IconData icon, required VoidCallback onPressed, required String tooltip}) { final colors = Theme.of(context).colorScheme; - final iconColor = color ?? colors.onSurface; return Container( margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - color: colors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - ), + decoration: BoxDecoration(color: colors.surface.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)), child: Material( color: Colors.transparent, child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(8), - child: Icon( - icon, - color: iconColor, - size: 22, - ), - ), + child: Container(padding: const EdgeInsets.all(8), child: Icon(icon, color: colors.onSurface, size: 22)), ), ), ); @@ -147,749 +124,182 @@ class _BookFormPageState extends State { backgroundColor: colors.surface, appBar: AppBar( title: Text(isEdit ? '编辑书籍' : '添加书籍'), - actions: [ - // 保存按钮 - _buildActionButton( - icon: Icons.save_outlined, - onPressed: _saveBook, - tooltip: '保存', - ), - const SizedBox(width: 8), - ], - ), - body: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - children: [ - // 封面选择 - 居中显示 - Center(child: _buildCoverPicker()), + actions: [ + _buildActionButton(icon: Icons.save_outlined, onPressed: _saveBook, tooltip: '保存'), + const SizedBox(width: 8), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + children: [ + // 封面 + Center(child: _buildCoverPicker()), + const SizedBox(height: 20), - const SizedBox(height: 24), + // 状态 + 评分 + _buildStatusRatingRow(), + const SizedBox(height: 24), - // 状态选择(简洁风格) - _buildStatusSelector(), - - const SizedBox(height: 16), - - // 评分 - 星星选择(简洁风格) - _buildStarRating(), - - const SizedBox(height: 32), - - // 信息卡片网格 - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - // 第一行:书名 + 别名 - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: '书名', - value: _titleController.text, - required: true, - icon: Icons.book_outlined, - onTap: () => _showTextInputDialog( - title: '书名', - initialValue: _titleController.text, - hint: '请输入书名', - onConfirm: (value) => setState(() => _titleController.text = value), - ), + // 信息卡片网格 + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // 书名 + 别名 + _halfCard('书名', _titleController.text, Icons.book_outlined, required: true, + onTap: () async { + final r = await TextInputPanel.show(context: context, title: '书名', initialValue: _titleController.text, hint: '请输入书名'); + if (r != null) setState(() => _titleController.text = r); + }, ), - ), - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: '别名', - value: _alternateTitles.isEmpty - ? '' - : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', - icon: Icons.alternate_email_outlined, - scrollable: true, - onTap: () => _showMultiValueDialog( - title: '添加别名', - initialValues: _alternateTitles, - hint: '输入别名', - onConfirm: (values) => setState(() => _alternateTitles = values), - ), + _halfCard('别名', _alternateTitles.isEmpty ? '' : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', Icons.alternate_email_outlined, + onTap: () async { + final r = await GenreSelectorPage.show(context: context, title: '添加别名', existingTags: [], initialSelected: _alternateTitles, hint: '输入别名'); + if (r != null) setState(() => _alternateTitles = r); + }, ), - ), - // 第二行:作者 + 出版社 - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: '作者', - value: _authors.isEmpty - ? '' - : '${_authors.length}人:${_authors.join('、')}', - icon: Icons.person_outline, - onTap: () => _showMultiValueDialog( - title: '添加作者', - initialValues: _authors, - hint: '输入作者姓名', - onConfirm: (values) => setState(() => _authors = values), - ), + // 作者 + 出版社 + _halfCard('作者', _authors.isEmpty ? '' : '${_authors.length}人:${_authors.join('、')}', Icons.person_outline, + onTap: () async { + final provider = context.read(); + final data = provider.books.map((b) => b.authors).toList(); + final r = await GenreSelectorPage.show(context: context, title: '选择作者', existingTagsFuture: compute(_collectUnique, data), initialSelected: _authors, hint: '如:余华、莫言'); + if (r != null) setState(() => _authors = r); + }, ), - ), - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: '出版社', - value: _publisherController.text, - icon: Icons.business_outlined, - onTap: () => _showTextInputDialog( - title: '出版社', - initialValue: _publisherController.text, - hint: '请输入出版社', - onConfirm: (value) => setState(() => _publisherController.text = value), - ), + _halfCard('出版社', _publisherController.text, Icons.business_outlined, + onTap: () async { + final r = await TextInputPanel.show(context: context, title: '出版社', initialValue: _publisherController.text, hint: '请输入出版社'); + if (r != null) setState(() => _publisherController.text = r); + }, ), - ), - // 第三行:类型 + ISBN - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: '类型', - value: _genres.isEmpty - ? '' - : '${_genres.length}个:${_genres.join('、')}', - icon: Icons.category_outlined, + // 类型 + ISBN + _halfCard('类型', _genres.isEmpty ? '' : '${_genres.length}个:${_genres.join('、')}', Icons.category_outlined, onTap: () async { final provider = context.read(); final tags = await provider.getTags('book_genre', excludeHidden: true); - final existingNames = tags.map((t) => t['name'] as String).toList(); - if (mounted) { - final result = await Navigator.push>( - context, - MaterialPageRoute( - builder: (_) => GenreSelectorPage( - title: '选择类型', - existingTags: existingNames, - initialSelected: _genres, - hint: '如:小说、历史、传记', - ), - ), - ); - if (result != null) setState(() => _genres = result); - } + if (!mounted) return; + final r = await GenreSelectorPage.show(context: context, title: '选择类型', existingTags: tags.map((t) => t['name'] as String).toList(), initialSelected: _genres, hint: '如:小说、历史、传记'); + if (r != null) setState(() => _genres = r); }, ), - ), - SizedBox( - width: (MediaQuery.of(context).size.width - 52) / 2, - height: 90, - child: _buildInfoCard( - label: 'ISBN', - value: _isbnController.text, - icon: Icons.qr_code_outlined, - onTap: () => _showTextInputDialog( - title: 'ISBN', - initialValue: _isbnController.text, - hint: '请输入ISBN编号', - onConfirm: (value) => setState(() => _isbnController.text = value), + _halfCard('ISBN', _isbnController.text, Icons.qr_code_outlined, + onTap: () async { + final r = await TextInputPanel.show(context: context, title: 'ISBN', initialValue: _isbnController.text, hint: '请输入ISBN编号'); + if (r != null) setState(() => _isbnController.text = r); + }, + ), + + // 出版时间 + SizedBox( + width: double.infinity, height: 90, + child: _buildInfoCard( + label: '出版时间', + value: _publishDate != null ? '${_publishDate!.year}.${_publishDate!.month.toString().padLeft(2, '0')}.${_publishDate!.day.toString().padLeft(2, '0')}' : '', + icon: Icons.date_range_outlined, + trailing: _publishDate != null + ? GestureDetector(onTap: () => setState(() => _publishDate = null), child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35))) + : null, + onTap: () => _selectPublishDate(), ), ), - ), - // 第四行:出版时间(独占一行) - SizedBox( - width: double.infinity, - height: 90, - child: _buildInfoCard( - label: '出版时间', - value: _publishDate != null - ? '${_publishDate!.year}.${_publishDate!.month.toString().padLeft(2, '0')}.${_publishDate!.day.toString().padLeft(2, '0')}' - : '', - icon: Icons.date_range_outlined, - onTap: () => _selectPublishDate(), + // 书籍简介 + SizedBox( + width: double.infinity, + child: _buildInfoCard(label: '书籍简介', value: _summaryController.text, icon: Icons.description_outlined, height: 160, scrollable: true, onTap: () => _editSummary()), ), - ), - - // 第五行:书籍简介(独占一行) - SizedBox( - width: double.infinity, - child: _buildInfoCard( - label: '书籍简介', - value: _summaryController.text, - icon: Icons.description_outlined, - height: 160, - scrollable: true, - onTap: () => _editSummary(), - ), - ), - ], - ), - - const SizedBox(height: 48), - ], - ), - ), - ), - ); - } - - /// 构建表单条目(标签 + 内容)- 书籍简介使用 - Widget _buildFormItem({required String label, required Widget child}) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), - ), - const SizedBox(height: 8), - child, - ], - ); - } - - /// 构建横向表单条目(标签在左,内容在右) - Widget _buildHorizontalFormItem({ - required String label, - required Widget child, - bool required = false, - }) { - final colors = Theme.of(context).colorScheme; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 48, - child: Text( - required ? '$label *' : label, - style: TextStyle( - fontSize: 13, - color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), - fontWeight: required ? FontWeight.w500 : FontWeight.normal, - ), - ), - ), - const SizedBox(width: 16), - Expanded(child: child), - ], - ); - } - - /// 构建横向多值条目(标签在左,内容在右) - Widget _buildHorizontalMultiValueItem({ - required String label, - required List values, - required String hint, - required String controllerKey, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - final controller = _getTagController(controllerKey); - final colors = Theme.of(context).colorScheme; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 48, - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ...values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ], - ), - ); - }), - // 输入框 - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 80, maxWidth: 120), - child: TextField( - controller: controller, - style: TextStyle(fontSize: 14, color: colors.onSurface), - decoration: InputDecoration( - hintText: values.isEmpty ? hint : '', - hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25)), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 6), - ), - onSubmitted: (value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - onAdd(trimmed); - controller.clear(); - } - }, - ), + ], ), + const SizedBox(height: 48), ], ), ), - ], - ); - } - - /// 构建多值条目 - Widget _buildMultiValueItem({ - required String label, - required List values, - required String hint, - required String controllerKey, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - final controller = _getTagController(controllerKey); - final colors = Theme.of(context).colorScheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一行:标签 + 添加按钮 - Row( - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), - ), - const Spacer(), - // 添加按钮(当输入框有内容时显示) - ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, child) { - final hasText = value.text.trim().isNotEmpty; - return GestureDetector( - onTap: hasText - ? () { - final text = controller.text.trim(); - if (text.isNotEmpty && !values.contains(text)) { - onAdd(text); - controller.clear(); - } - } - : null, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: hasText ? colors.primary : colors.outline, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add, - size: 14, - color: hasText ? colors.primary : colors.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(width: 2), - Text( - '添加', - style: TextStyle( - fontSize: 12, - color: hasText ? colors.primary : colors.onSurface.withValues(alpha: 0.25), - ), - ), - ], - ), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - // 第二行:已选标签 + 输入框 - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ...values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - border: Border.all(color: colors.outline), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ], - ), - ); - }), - // 输入框 - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 100, maxWidth: 150), - child: TextField( - controller: controller, - style: TextStyle(fontSize: 14, color: colors.onSurface), - decoration: InputDecoration( - hintText: values.isEmpty ? hint : '', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 5), - ), - onSubmitted: (value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - onAdd(trimmed); - controller.clear(); - } - }, - ), - ), - ], - ), - ], - ); - } - - - /// 构建信息卡片(用于网格布局) - Widget _buildInfoCard({ - required String label, - required String value, - required VoidCallback onTap, - bool required = false, - IconData? icon, - double? height, - bool scrollable = false, - }) { - final hasValue = value.isNotEmpty; - final colors = Theme.of(context).colorScheme; - - // 构建值显示部分 - Widget buildContent() { - if (scrollable && height != null) { - // 可滚动模式(仅书籍简介使用) - return Flexible( - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Text( - hasValue ? value : '点击填写', - style: TextStyle( - fontSize: 15, - color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), - fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, - ), - ), - ), - ); - } else { - // 普通模式:只显示一行 - return Text( - hasValue ? value : '点击填写', - style: TextStyle( - fontSize: 15, - color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), - fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - } - - return GestureDetector( - onTap: onTap, - child: Container( - height: height, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.outline), - boxShadow: [ - BoxShadow( - color: colors.onSurface.withValues(alpha: 0.018), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min, - children: [ - // 标签行 - Row( - children: [ - if (icon != null) ...[ - Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 6), - ], - Text( - required ? '$label *' : label, - style: TextStyle( - fontSize: 12, - color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), - fontWeight: required ? FontWeight.w500 : FontWeight.normal, - ), - ), - ], - ), - const SizedBox(height: 8), - // 值显示 - buildContent(), - ], - ), ), ); } - /// 构建状态选择器(简洁风格) - Widget _buildStatusSelector() { + /// 半宽卡片快捷方法 + Widget _halfCard(String label, String value, IconData icon, {bool required = false, VoidCallback? onTap}) { + return SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard(label: label, value: value, icon: icon, required: required, onTap: onTap ?? () {}), + ); + } + + // ─── 状态 + 评分合并行 ─── + + Widget _buildStatusRatingRow() { final colors = Theme.of(context).colorScheme; - return Row( - children: [ - Text( - '状态', - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(width: 16), - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ + final currentRating = double.tryParse(_ratingController.text) ?? 0; + final starRating = currentRating / 2; + final hasRating = _ratingController.text.isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Column(children: [ + // 状态 + Row(children: [ + Text('状态', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ _buildStatusOption('想读', 'want_to_read'), _buildStatusOption('在读', 'reading'), _buildStatusOption('已读', 'read'), - ], + ]), ), - ), - ], - ); - } - - /// 构建星星评分(支持手动输入) - Widget _buildStarRating() { - final colors = Theme.of(context).colorScheme; - final hasRating = _ratingController.text.isNotEmpty; - return Row( - children: [ - Text( - '评分', - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(width: 16), - // 星星选择 - _buildStarSelector(), - const SizedBox(width: 12), - // 手动输入框 - _buildRatingInputField(), - // 清除按钮 - if (hasRating) ...[ + ]), + const SizedBox(height: 12), + // 评分 + Row(children: [ + Text('评分', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + ...List.generate(5, (i) { + final sv = i + 1; + final filled = sv <= starRating; + final half = sv == starRating.ceil() && starRating % 1 != 0; + return GestureDetector( + onTap: () => setState(() => _ratingController.text = (sv * 2).toString()), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Icon(half ? Icons.star_half : (filled ? Icons.star : Icons.star_border), size: 22, color: (filled || half) ? const Color(0xFFFFB800) : colors.outline), + ), + ); + }), const SizedBox(width: 8), - GestureDetector( - onTap: () => setState(() => _ratingController.clear()), - child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), - ), - ], - ], - ); - } - - /// 构建星星选择器 - Widget _buildStarSelector() { - final currentRating = double.tryParse(_ratingController.text) ?? 0; - final starRating = currentRating / 2; - final colors = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (index) { - final starValue = index + 1; - final scoreValue = starValue * 2; - final isFilled = starValue <= starRating; - final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; - - return InkWell( - onTap: () { - setState(() { - _ratingController.text = scoreValue.toString(); - }); - }, - borderRadius: BorderRadius.circular(4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), - child: Icon( - isHalf - ? Icons.star_half - : isFilled - ? Icons.star - : Icons.star_border, - size: 24, - color: isFilled || isHalf - ? const Color(0xFFFFB800) - : colors.outline, - ), + Container( + width: 48, height: 28, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)), + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + inputFormatters: [_RatingInputFormatter()], + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), + decoration: InputDecoration(hintText: '0-10', hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 6), isDense: true), + onChanged: (_) => setState(() {}), ), - ); - }), - ), - ); - } - - /// 构建评分输入框(支持0-10,保留1位小数) - Widget _buildRatingInputField() { - final colors = Theme.of(context).colorScheme; - return Container( - width: 56, - height: 32, - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - child: TextFormField( - controller: _ratingController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colors.onSurface, - ), - decoration: InputDecoration( - hintText: '-', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - ), - onChanged: (value) { - if (value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating != null) { - if (rating > 10) { - _ratingController.text = '10.0'; - } else if (rating < 0) { - _ratingController.text = '0.0'; - } - } - } - setState(() {}); - }, - ), - ); - } - - /// 构建纵向日期条目(标签在上,日期在下) - Widget _buildVerticalDateItem({ - required String label, - required DateTime? date, - required VoidCallback onTap, - required VoidCallback onClear, - }) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: onTap, - child: Row( - children: [ - Expanded( - child: Text( - date != null - ? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}' - : '选择日期', - style: TextStyle( - fontSize: 15, - color: date != null - ? colors.onSurface - : colors.onSurface.withValues(alpha: 0.25), - ), - ), - ), - if (date != null) - GestureDetector( - onTap: onClear, - child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ], ), - ), - ], + if (hasRating) ...[ + const SizedBox(width: 6), + GestureDetector(onTap: () => setState(() => _ratingController.clear()), child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ]), + ]), ); } - /// 选择出版时间 - Future _selectPublishDate() async { - final picked = await showDatePicker( - context: context, - initialDate: _publishDate ?? DateTime.now(), - firstDate: DateTime(1900), - lastDate: DateTime.now().add(const Duration(days: 365 * 5)), - builder: (context, child) => child!, - ); - - if (picked != null) { - setState(() => _publishDate = picked); - } - } - - /// 构建状态选项 Widget _buildStatusOption(String label, String value) { final isSelected = _status == value; final colors = Theme.of(context).colorScheme; - return GestureDetector( onTap: () => setState(() => _status = value), child: AnimatedContainer( @@ -898,390 +308,239 @@ class _BookFormPageState extends State { decoration: BoxDecoration( color: isSelected ? colors.surface : Colors.transparent, borderRadius: BorderRadius.circular(6), - boxShadow: isSelected - ? [ - BoxShadow( - color: colors.onSurface.withValues(alpha: 0.03), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - color: isSelected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), - ), + boxShadow: isSelected ? [BoxShadow(color: colors.onSurface.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2))] : null, ), + child: Text(label, style: TextStyle(fontSize: 14, fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, color: isSelected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4))), ), ); } - /// 构建封面选择器 + // ─── 封面选择器 ─── + Widget _buildCoverPicker() { final hasCover = _coverPath != null && _coverPath!.isNotEmpty; final colors = Theme.of(context).colorScheme; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: _pickCover, - child: Container( - width: 120, - height: 170, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + return Column(mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + onTap: _pickCover, + child: Container( + width: 120, height: 170, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), + clipBehavior: Clip.antiAlias, + child: Stack(alignment: Alignment.center, children: [ + if (hasCover) FadeInLocalImage(path: _coverPath, fit: BoxFit.cover) else _buildCoverPlaceholder(), + if (_isDownloading) Container(color: Colors.black.withValues(alpha: 0.4), child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + ]), + ), + ), + if (hasCover) + Padding( + padding: const EdgeInsets.only(top: 10), + child: GestureDetector( + onTap: () => setState(() => _coverPath = null), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.delete_outline, size: 14, color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 4), + Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))), + ]), ), - clipBehavior: Clip.antiAlias, - child: hasCover - ? FadeInLocalImage( - path: _coverPath, - fit: BoxFit.cover, - ) - : _buildCoverPlaceholder(), ), ), - // 清空封面按钮(仅当有封面时显示) - if (hasCover) - Padding( - padding: const EdgeInsets.only(top: 10), - child: GestureDetector( - onTap: () => setState(() => _coverPath = null), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.delete_outline, - size: 14, - color: colors.onSurface.withValues(alpha: 0.6), - ), - const SizedBox(width: 4), - Text( - '移除封面', - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - ), - ), - ], - ); + ]); } Widget _buildCoverPlaceholder() { final colors = Theme.of(context).colorScheme; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.image_outlined, - size: 32, - color: colors.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(height: 8), - Text( - '封面', - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ], - ); + return Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.image_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(height: 8), + Text('封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ]); } - /// 选择封面 Future _pickCover() async { final colors = Theme.of(context).colorScheme; - // 显示选择对话框 - final result = await showModalBottomSheet( + await showModalBottomSheet( context: context, backgroundColor: colors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) { - final colors = Theme.of(context).colorScheme; + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) { + final c = Theme.of(ctx).colorScheme; return SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 顶部指示条 - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colors.outline, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 20), - // 标题 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '添加封面', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - ), - ), - const SizedBox(height: 16), - // 本地图片选项 - _buildCoverOption( - icon: Icons.photo_library_outlined, - title: '从相册选择', - onTap: () { - Navigator.pop(context, 0); - }, - ), - // 网络链接选项 - _buildCoverOption( - icon: Icons.link_outlined, - title: '网络链接', - onTap: () { - Navigator.pop(context, 1); - }, - ), - ], - ), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: c.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 16), + Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20), child: Text('添加封面', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: c.onSurface)))), + const SizedBox(height: 8), + ListTile( + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: c.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.photo_library_outlined, size: 20, color: c.onSurface.withValues(alpha: 0.6))), + title: Text('从相册选择', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: c.onSurface)), + trailing: Icon(Icons.chevron_right, color: c.onSurface.withValues(alpha: 0.25)), + onTap: () { Navigator.pop(ctx); _pickCoverFromGallery(); }, + ), + Divider(height: 0.5, color: c.outlineVariant), + ListTile( + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: c.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.link_outlined, size: 20, color: c.onSurface.withValues(alpha: 0.6))), + title: Text('网络链接', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: c.onSurface)), + trailing: Icon(Icons.chevron_right, color: c.onSurface.withValues(alpha: 0.25)), + onTap: () { Navigator.pop(ctx); _pickCoverFromUrl(); }, + ), + ]), ), ); }, ); - - if (result == null) return; - - if (result == 0) { - await _pickCoverFromGallery(); - } else if (result == 1) { - await _pickCoverFromUrl(); - } } - /// 从相册选择封面 Future _pickCoverFromGallery() async { try { - final XFile? pickedFile = await _picker.pickImage( - source: ImageSource.gallery, - maxWidth: 800, - maxHeight: 1200, - imageQuality: 85, - ); - - if (pickedFile != null) { - // 生成文件名 + final picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 800, maxHeight: 1200, imageQuality: 85); + if (picked != null) { final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - // 如果是编辑模式,使用现有书籍ID;如果是新建模式,使用临时ID(保存时会替换) final bookId = widget.book?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); - - // 保存到新的路径结构: images/books/{bookId}/{fileName} - final targetPath = await ImagePathHelper.instance.getBookCoverPath( - bookId, - fileName - ); + final targetPath = await ImagePathHelper.instance.getBookCoverPath(bookId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); - - await File(pickedFile.path).copy(targetPath); - + await File(picked.path).copy(targetPath); setState(() => _coverPath = targetPath); } } catch (e) { - if (mounted) { - ToastUtil.show(context, '选择封面失败: $e'); - } + if (mounted) ToastUtil.show(context, '选择封面失败: $e'); } } - /// 从网络链接选择封面 Future _pickCoverFromUrl() async { final urlController = TextEditingController(); - final confirmed = await showDialog( context: context, - builder: (context) { - final colors = Theme.of(context).colorScheme; + builder: (ctx) { + final c = Theme.of(ctx).colorScheme; return AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('添加网络图片'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '请输入图片链接地址', - style: TextStyle( - fontSize: 14, - color: colors.onSurface.withValues(alpha: 0.6), - ), + backgroundColor: c.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: c.onSurface)), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: c.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 12), + TextField( + controller: urlController, keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: c.onSurface), + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: c.onSurface.withValues(alpha: 0.25)), + filled: true, fillColor: c.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: c.primary, width: 1)), ), - const SizedBox(height: 12), - TextField( - controller: urlController, - decoration: InputDecoration( - hintText: 'https://book.doban.com/image.jpg', - hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), - border: const UnderlineInputBorder(), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - ), - style: const TextStyle(fontSize: 14), - keyboardType: TextInputType.url, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text('确定', style: TextStyle(color: colors.onSurface)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: c.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: c.primary, foregroundColor: c.onPrimary, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('确定'), ), ], ); }, ); - if (confirmed != true) return; - final url = urlController.text.trim(); - if (url.isEmpty) { - ToastUtil.show(context, '请输入图片链接'); - return; - } + urlController.dispose(); + if (confirmed != true || url.isEmpty) return; + setState(() => _isDownloading = true); try { - // 下载网络图片 - final response = await http.get( - Uri.parse(url), - headers: { - 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - 'Referer': 'https://book.douban.com/', - }, - ); + final response = await http.get(Uri.parse(url), headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }); + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); + final ct = response.headers['content-type']; + if (ct != null && !ct.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); - if (response.statusCode != 200) { - throw Exception('下载失败: HTTP ${response.statusCode}'); - } - - // 检查内容类型 - final contentType = response.headers['content-type']; - if (contentType != null && !contentType.startsWith('image/')) { - throw Exception('链接返回的不是图片'); - } - - // 检查文件大小(最大 10MB) - if (response.bodyBytes.length > 10 * 1024 * 1024) { - throw Exception('图片太大'); - } - - // 生成文件名 final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - // 如果是编辑模式,使用现有书籍ID;如果是新建模式,使用临时ID final bookId = widget.book?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); - - // 保存到新的路径结构 - final targetPath = await ImagePathHelper.instance.getBookCoverPath( - bookId, - fileName - ); + final targetPath = await ImagePathHelper.instance.getBookCoverPath(bookId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); - - // 写入文件 await File(targetPath).writeAsBytes(response.bodyBytes); - setState(() => _coverPath = targetPath); - - if (mounted) { - ToastUtil.show(context, '添加成功'); - } } catch (e) { - if (mounted) { - ToastUtil.show(context, '添加失败: $e'); - } + debugPrint('封面下载失败: $e'); + if (mounted) ToastUtil.show(context, '下载失败: $e'); + } finally { + if (mounted) setState(() => _isDownloading = false); } } - /// 构建封面选项 - Widget _buildCoverOption({ - required IconData icon, - required String title, + // ─── Info Card ─── + + Widget _buildInfoCard({ + required String label, + required String value, required VoidCallback onTap, + bool required = false, + IconData? icon, + Widget? trailing, + double? height, + bool scrollable = false, }) { + final hasValue = value.isNotEmpty; final colors = Theme.of(context).colorScheme; - return InkWell( + + Widget buildContent() { + if (scrollable && height != null) { + return Flexible( + child: SingleChildScrollView(physics: const BouncingScrollPhysics(), + child: Text(hasValue ? value : '点击填写', style: TextStyle(fontSize: 15, color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal))), + ); + } + return Text(hasValue ? value : '点击填写', style: TextStyle(fontSize: 15, color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal), maxLines: 1, overflow: TextOverflow.ellipsis); + } + + return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - icon, - size: 22, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(width: 16), - Text( - title, - style: TextStyle( - fontSize: 16, - color: colors.onSurface, - ), - ), - const Spacer(), - Icon( - Icons.chevron_right, - color: colors.onSurface.withValues(alpha: 0.25), - size: 20, - ), - ], + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outline), + boxShadow: [BoxShadow(color: colors.onSurface.withValues(alpha: 0.018), blurRadius: 8, offset: const Offset(0, 2))], ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min, children: [ + Row(children: [ + if (icon != null) ...[Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 6)], + Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), fontWeight: required ? FontWeight.w500 : FontWeight.normal)), + if (trailing != null) ...[const Spacer(), trailing], + ]), + const SizedBox(height: 8), + buildContent(), + ]), ), ); } - /// 检查表单是否有内容 + // ─── 数据操作 ─── + + Future _selectPublishDate() async { + final picked = await showDatePicker(context: context, initialDate: _publishDate ?? DateTime.now(), firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5))); + if (picked != null) setState(() => _publishDate = picked); + } + + Future _editSummary() async { + final result = await Navigator.push(context, MaterialPageRoute(builder: (_) => _SummaryEditorPage(initialText: _summaryController.text))); + if (result != null) setState(() => _summaryController.text = result); + } + bool _hasContent() { if (widget.book != null) return true; if (_titleController.text.trim().isNotEmpty) return true; @@ -1295,33 +554,19 @@ class _BookFormPageState extends State { return false; } - /// 离开确认 Future _confirmLeave() async { if (!_hasContent()) return true; final colors = Theme.of(context).colorScheme; final result = await showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('当前内容未保存,确定要离开吗?', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + content: Text('当前内容未保存,确定要离开吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: colors.error, - foregroundColor: colors.onError, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), child: const Text('离开'), ), ], @@ -1331,580 +576,103 @@ class _BookFormPageState extends State { return result ?? false; } - /// 保存书籍 Future _saveBook() async { - if (!_formKey.currentState!.validate()) { - return; - } + if (!_formKey.currentState!.validate()) return; - final rating = _ratingController.text.isNotEmpty - ? double.tryParse(_ratingController.text) - : null; + try { + final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; + final now = DateTime.now(); - final now = DateTime.now(); - - if (widget.book == null) { - // 生成新的书籍ID - final newBookId = now.millisecondsSinceEpoch.toString(); - - // 如果有封面,需要移动到正确的ID目录 - String? finalCoverPath; - if (_coverPath != null && _coverPath!.isNotEmpty) { - finalCoverPath = await _moveCoverToNewId(_coverPath!, newBookId); + if (widget.book == null) { + final newBookId = now.millisecondsSinceEpoch.toString(); + String? finalCoverPath; + if (_coverPath != null && _coverPath!.isNotEmpty) { + finalCoverPath = await _moveCoverToNewId(_coverPath!, newBookId); + } + final newBook = Book( + id: newBookId, title: _titleController.text.trim(), coverPath: finalCoverPath, + authors: _authors, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), + genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, + isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, + publishDate: _publishDate, createdAt: now, updatedAt: now, + ); + await context.read().addBook(newBook); + } else { + final updatedBook = widget.book!.copyWith( + title: _titleController.text.trim(), coverPath: _coverPath, + authors: _authors, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), + genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, + isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, + publishDate: _publishDate, updatedAt: now, + ); + await context.read().updateBook(updatedBook); } - final newBook = Book( - id: newBookId, - title: _titleController.text.trim(), - coverPath: finalCoverPath, - authors: _authors, - alternateTitles: _alternateTitles, - publisher: _publisherController.text.trim(), - genres: _genres, - summary: _summaryController.text.trim(), - rating: rating, - status: _status, - isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, - createdAt: now, - updatedAt: now, - ); - - await context.read().addBook(newBook); - } else { - final updatedBook = widget.book!.copyWith( - title: _titleController.text.trim(), - coverPath: _coverPath, - authors: _authors, - alternateTitles: _alternateTitles, - publisher: _publisherController.text.trim(), - genres: _genres, - summary: _summaryController.text.trim(), - rating: rating, - status: _status, - isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, - updatedAt: now, - ); - - await context.read().updateBook(updatedBook); + if (!mounted) return; + ToastUtil.show(context, widget.book == null ? '添加成功' : '更新成功'); + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ToastUtil.show(context, '保存失败: $e'); } - - if (!mounted) return; - - ToastUtil.show(context, widget.book == null ? '添加成功' : '更新成功'); - - Navigator.pop(context); } - /// 将封面从临时ID目录移动到新的书籍ID目录 Future _moveCoverToNewId(String currentPath, String newBookId) async { - // 检查是否已经在正确的目录中(兼容 Windows 路径分隔符) final normalizedPath = currentPath.replaceAll('\\', '/'); - if (normalizedPath.contains('/books/$newBookId/')) { - return currentPath; - } - - // 提取文件名 + if (normalizedPath.contains('/books/$newBookId/')) return currentPath; final fileName = p.basename(currentPath); - - // 获取新路径 - final newPath = await ImagePathHelper.instance.getBookCoverPath( - newBookId, - fileName - ); - - // 确保目标目录存在 + final newPath = await ImagePathHelper.instance.getBookCoverPath(newBookId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath)); - - // 移动文件 final currentFile = File(currentPath); if (await currentFile.exists()) { await currentFile.rename(newPath); - - // 删除空的临时目录 - final tempDir = Directory(p.dirname(currentPath)); - if (await tempDir.exists()) { - try { - await tempDir.delete(recursive: true); - } catch (e) { - // 忽略删除目录失败的情况 - } - } - + try { await Directory(p.dirname(currentPath)).delete(recursive: true); } catch (_) {} return newPath; } - return null; } - - /// 全屏编辑书籍简介 - Future _editSummary() async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => _SummaryEditorPage(initialText: _summaryController.text), - ), - ); - if (result != null) { - setState(() => _summaryController.text = result); - } - } - - /// 显示文本输入对话框 - Future _showTextInputDialog({ - required String title, - required String initialValue, - required Function(String) onConfirm, - int maxLines = 1, - String hint = '', - }) async { - final result = await showDialog( - context: context, - builder: (context) => _TextInputDialog( - title: title, - initialValue: initialValue, - hint: hint, - maxLines: maxLines, - ), - ); - - if (result != null) { - onConfirm(result); - } - } - - /// 显示多值输入对话框 - Future _showMultiValueDialog({ - required String title, - required List initialValues, - required String hint, - required Function(List) onConfirm, - List existingTags = const [], - }) async { - final result = await showDialog>( - context: context, - builder: (context) => _MultiValueDialog( - title: title, - initialValues: initialValues, - hint: hint, - existingTags: existingTags, - ), - ); - - if (result != null) { - onConfirm(result); - } - } } -/// 文本输入对话框组件 -class _TextInputDialog extends StatefulWidget { - final String title; - final String initialValue; - final String hint; - final int maxLines; - - const _TextInputDialog({ - required this.title, - required this.initialValue, - required this.hint, - required this.maxLines, - }); - - @override - State<_TextInputDialog> createState() => _TextInputDialogState(); -} - -class _TextInputDialogState extends State<_TextInputDialog> { - late final TextEditingController controller; - - @override - void initState() { - super.initState(); - controller = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return AlertDialog( - backgroundColor: colors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - widget.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - content: TextField( - controller: controller, - maxLines: widget.maxLines, - style: TextStyle(fontSize: 15, color: colors.onSurface), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('确定'), - ), - ], - ); - } -} - -/// 多值输入对话框组件 -class _MultiValueDialog extends StatefulWidget { - final String title; - final List initialValues; - final String hint; - final List existingTags; - - const _MultiValueDialog({ - required this.title, - required this.initialValues, - required this.hint, - this.existingTags = const [], - }); - - @override - State<_MultiValueDialog> createState() => _MultiValueDialogState(); -} - -class _MultiValueDialogState extends State<_MultiValueDialog> { - late List values; - final controller = TextEditingController(); - - @override - void initState() { - super.initState(); - values = List.from(widget.initialValues); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void _addValue(String value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - setState(() { - values.add(trimmed); - }); - controller.clear(); - } - } - - void _removeValue(int index) { - setState(() { - values.removeAt(index); - }); - } - - void _toggleExistingTag(String tag) { - setState(() { - if (values.contains(tag)) { - values.remove(tag); - } else { - values.add(tag); - } - }); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final availableTags = widget.existingTags - .where((t) => !values.contains(t)) - .toList(); - - return AlertDialog( - backgroundColor: colors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - widget.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - content: SizedBox( - width: double.maxFinite, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.55, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 可滚动的标签区域 - if (values.isNotEmpty || availableTags.isNotEmpty) - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 已选择的标签 - if (values.isNotEmpty) ...[ - Text( - '已选择', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: values.map((v) { - final display = v.length > 8 ? '${v.substring(0, 8)}...' : v; - return Container( - padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - display, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colors.onPrimary, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () { - setState(() => values.remove(v)); - }, - child: Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: colors.onPrimary.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.close, size: 12, color: colors.onPrimary), - ), - ), - ], - ), - ); - }).toList(), - ), - if (availableTags.isNotEmpty) ...[ - const SizedBox(height: 16), - Divider(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 16), - ], - ], - // 已有类型 - if (availableTags.isNotEmpty) ...[ - Text( - '已有类型', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: availableTags.map((tag) { - final display = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; - return GestureDetector( - onTap: () { - setState(() => values.add(tag)); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: colors.outline, - width: 0.5, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 4), - Text( - display, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colors.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ), - ), - // 分隔线 + 固定底部输入框 - if (values.isNotEmpty || availableTags.isNotEmpty) ...[ - const SizedBox(height: 12), - Divider(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 12), - ], - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - style: TextStyle(fontSize: 15, color: colors.onSurface), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: _addValue, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _addValue(controller.text), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.add, size: 20, color: colors.onPrimary), - ), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))), - ), - ElevatedButton( - onPressed: () { - _addValue(controller.text); - Navigator.pop(context, values); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('确定'), - ), - ], - ); - } -} - -/// 书籍简介全屏编辑页 +/// 书籍简介编辑页 class _SummaryEditorPage extends StatefulWidget { final String initialText; const _SummaryEditorPage({required this.initialText}); - @override State<_SummaryEditorPage> createState() => _SummaryEditorPageState(); } class _SummaryEditorPageState extends State<_SummaryEditorPage> { late final TextEditingController _controller; - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.initialText); - } - + void initState() { super.initState(); _controller = TextEditingController(text: widget.initialText); } @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - + void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surface, - appBar: AppBar( - title: const Text('书籍简介'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, _controller.text.trim()), - child: Text('完成', style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, - )), - ), - const SizedBox(width: 8), - ], - ), - body: TextField( - controller: _controller, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, + appBar: AppBar(title: const Text('书籍简介'), actions: [ + TextButton(onPressed: () => Navigator.pop(context, _controller.text.trim()), child: Text('完成', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary))), + const SizedBox(width: 8), + ]), + body: TextField(controller: _controller, maxLines: null, expands: true, textAlignVertical: TextAlignVertical.top, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6), - decoration: InputDecoration( - hintText: '写下书籍简介...', - hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), - contentPadding: const EdgeInsets.all(20), - border: InputBorder.none, - ), + decoration: InputDecoration(hintText: '写下书籍简介...', hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), contentPadding: const EdgeInsets.all(20), border: InputBorder.none), ), ); } } + +/// 评分输入格式化器:只允许 0-10,最多1位小数 +class _RatingInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + if (!RegExp(r'^\d{0,2}\.?\d{0,1}$').hasMatch(text)) return oldValue; + final n = double.tryParse(text); + if (n != null && n > 10) return oldValue; + return newValue; + } +} diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index ab29aa6..d6ee007 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -25,8 +25,6 @@ class _BookTabPageState extends State { int _offset = 0; int _lastStatusIndex = -1; bool _initialized = false; - int _lastDataCount = -1; - DateTime? _lastUpdatedAt; late ScrollController _scrollController; AppProvider? _provider; int _lastScrollSignal = 0; @@ -42,8 +40,6 @@ class _BookTabPageState extends State { final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); - _lastDataCount = provider.books.length; - if (provider.books.isNotEmpty) _lastUpdatedAt = provider.books.first.updatedAt; _loadFirst(); }); } @@ -67,15 +63,8 @@ class _BookTabPageState extends State { } } - final count = provider.books.length; - final latest = provider.books.isNotEmpty ? provider.books.first.updatedAt : null; - if (count != _lastDataCount || latest != _lastUpdatedAt) { - _lastDataCount = count; - _lastUpdatedAt = latest; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadFirst(); - }); - } + // 数据变化时刷新列表(排序/评分/新增等) + _loadFirst(); } void _onScroll() { @@ -91,7 +80,7 @@ class _BookTabPageState extends State { _initialized = true; final status = _statusMap[statusIdx] ?? 'read'; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadBooksPaged(status: status, offset: 0); + final list = await provider.loadBooksPaged(status: status, offset: 0, sortMode: UserPrefs().bookSortMode); if (!mounted) return; setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); } @@ -101,7 +90,7 @@ class _BookTabPageState extends State { setState(() => _isLoading = true); final provider = context.read(); final status = _statusMap[provider.bookStatusIndex] ?? 'read'; - final list = await provider.loadBooksPaged(status: status, offset: _offset); + final list = await provider.loadBooksPaged(status: status, offset: _offset, sortMode: UserPrefs().bookSortMode); if (!mounted) return; setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); } diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index 947ccb0..70b4034 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -296,6 +296,24 @@ class _MainContentPageState extends State { provider.setMainTabIndex(tab.originalIndex); Future.delayed(const Duration(milliseconds: 400), () => _isTabTap = false); }, + onLongPress: tab.label == '影视' + ? () => _showSortMenu(context, '影视排序', UserPrefs().movieSortMode, [ + (0, '按更新时间排序', Icons.update), + (1, '按创建时间排序', Icons.calendar_today_outlined), + (2, '按评分排序', Icons.star_outline), + ], (v) { UserPrefs().setMovieSortMode(v); context.read().loadMovies(); }) + : tab.label == '阅读' + ? () => _showSortMenu(context, '书籍排序', UserPrefs().bookSortMode, [ + (0, '按更新时间排序', Icons.update), + (1, '按创建时间排序', Icons.calendar_today_outlined), + (2, '按评分排序', Icons.star_outline), + ], (v) { UserPrefs().setBookSortMode(v); context.read().loadBooks(); }) + : tab.label == '笔记' + ? () => _showSortMenu(context, '笔记排序', UserPrefs().noteSortMode, [ + (0, '按更新时间排序', Icons.update), + (1, '按创建时间排序', Icons.calendar_today_outlined), + ], (v) { UserPrefs().setNoteSortMode(v); context.read().loadNotes(); }) + : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ @@ -337,7 +355,45 @@ class _MainContentPageState extends State { ); } - // ─── PageView 内容区 ───────────────────────────────── + void _showSortMenu(BuildContext context, String title, int current, List<(int, String, IconData)> options, ValueChanged onSelected) { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), + const SizedBox(height: 8), + for (int i = 0; i < options.length; i++) ...[ + if (i > 0) Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant), + _sortOption(ctx, options[i].$1, options[i].$2, options[i].$3, current, colors, onSelected), + ], + const SizedBox(height: 12), + ]), + ), + ); + } + + Widget _sortOption(BuildContext ctx, int value, String label, IconData icon, int current, ColorScheme colors, ValueChanged onSelected) { + final selected = current == value; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, size: 20, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)), + trailing: selected ? Icon(Icons.check, size: 20, color: colors.primary) : null, + onTap: () { + Navigator.pop(ctx); + onSelected(value); + }, + ); + } + + // ─── PageView 内容区 ─── Widget _buildTabContent() { return Consumer( @@ -359,7 +415,11 @@ class _MainContentPageState extends State { } if (_isTabTap && _pageController.hasClients) { - _pageController.animateToPage(safeIndex, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + _isTabTap = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _pageController.animateToPage(safeIndex, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); } return PageView( diff --git a/lib/pages/media_calendar_page.dart b/lib/pages/media_calendar_page.dart new file mode 100644 index 0000000..428eea3 --- /dev/null +++ b/lib/pages/media_calendar_page.dart @@ -0,0 +1,435 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'movies/movie_detail_page.dart'; +import 'movies/movie_form_page.dart'; +import 'book/book_detail_page.dart'; +import 'book/book_form_page.dart'; + +/// 书影日历 - 按月展示影视/书籍添加记录 +class MediaCalendarPage extends StatefulWidget { + const MediaCalendarPage({super.key}); + + @override + State createState() => _MediaCalendarPageState(); +} + +class _MediaCalendarPageState extends State { + late DateTime _currentMonth; + DateTime? _selectedDay; + + // {DateTime(dayOnly): [{path, title, type, data}]} + late Map> _dayItems; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _currentMonth = DateTime(now.year, now.month); + _selectedDay = DateTime(now.year, now.month, now.day); + _buildDayMap(); + } + + void _buildDayMap() { + final provider = context.read(); + final map = >{}; + + for (final m in provider.movies.where((m) => !m.isDeleted)) { + if (m.posterPath == null || m.posterPath!.isEmpty) continue; + final day = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day); + map.putIfAbsent(day, () => []); + map[day]!.add(_CalendarItem( + path: m.posterPath!, + title: m.title, + type: 'movie', + data: m, + )); + } + + for (final b in provider.books.where((b) => !b.isDeleted)) { + if (b.coverPath == null || b.coverPath!.isEmpty) continue; + final day = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day); + map.putIfAbsent(day, () => []); + map[day]!.add(_CalendarItem( + path: b.coverPath!, + title: b.title, + type: 'book', + data: b, + )); + } + + _dayItems = map; + } + + void _prevMonth() { + setState(() { + _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); + _selectedDay = null; + }); + } + + void _nextMonth() { + setState(() { + _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); + _selectedDay = null; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: const Text('书影日历')), + body: Column( + children: [ + _buildMonthHeader(colors), + _buildWeekdayLabels(colors), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildCalendarGrid(colors, today), + if (_selectedDay != null) _buildSelectedDayDetail(colors), + const SizedBox(height: 80), + ], + ), + ), + ), + ], + ), + floatingActionButton: _selectedDay != null + ? FloatingActionButton( + onPressed: () => _showAddMenu(context), + backgroundColor: colors.primary, + child: Icon(Icons.add, color: colors.onPrimary), + ) + : null, + ); + } + + // ─── 月份标题 ─── + + Widget _buildMonthHeader(ColorScheme colors) { + final months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + IconButton( + onPressed: _prevMonth, + icon: Icon(Icons.chevron_left, color: colors.onSurface.withValues(alpha: 0.6)), + ), + Expanded( + child: Text( + '${_currentMonth.year}年${months[_currentMonth.month - 1]}', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + ), + IconButton( + onPressed: _nextMonth, + icon: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.6)), + ), + ], + ), + ); + } + + // ─── 星期头 ─── + + Widget _buildWeekdayLabels(ColorScheme colors) { + const weekdays = ['一', '二', '三', '四', '五', '六', '日']; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: weekdays.map((d) => Expanded( + child: Center(child: Text(d, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.35)))), + )).toList(), + ), + ); + } + + // ─── 日历网格 ─── + + static const double _cellHeight = 62; + + Widget _buildCalendarGrid(ColorScheme colors, DateTime today) { + final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1); + final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); + final startOffset = firstDay.weekday - 1; + final totalDays = lastDay.day; + final totalCells = startOffset + totalDays; + final rows = (totalCells / 7).ceil(); + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Column( + children: List.generate(rows, (row) { + return SizedBox( + height: _cellHeight, + child: Row( + children: List.generate(7, (col) { + final index = row * 7 + col; + if (index < startOffset || index >= startOffset + totalDays) { + return const Expanded(child: SizedBox()); + } + final day = index - startOffset + 1; + final date = DateTime(_currentMonth.year, _currentMonth.month, day); + final isToday = date == today; + final isSelected = _selectedDay == date; + final items = _dayItems[date] ?? []; + return Expanded(child: _buildDayCell(colors, date, day, isToday, isSelected, items)); + }), + ), + ); + }), + ), + ); + } + + Widget _buildDayCell(ColorScheme colors, DateTime date, int day, bool isToday, bool isSelected, List<_CalendarItem> items) { + final hasItems = items.isNotEmpty; + return GestureDetector( + onTap: () => setState(() => _selectedDay = date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSelected + ? colors.primary.withValues(alpha: 0.08) + : hasItems + ? colors.surfaceContainerHigh + : null, + borderRadius: BorderRadius.circular(10), + border: isToday + ? Border.all(color: colors.primary, width: 1.5) + : isSelected + ? Border.all(color: colors.primary.withValues(alpha: 0.3), width: 1) + : null, + ), + child: hasItems + ? _buildImageCell(colors, day, items, isToday) + : Center( + child: Text( + '$day', + style: TextStyle( + fontSize: 13, + fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + color: isToday + ? colors.primary + : colors.onSurface.withValues(alpha: 0.35), + ), + ), + ), + ), + ); + } + + Widget _buildImageCell(ColorScheme colors, int day, List<_CalendarItem> items, bool isToday) { + return ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Stack( + fit: StackFit.expand, + children: [ + Image( + image: FileImage(File(items.first.path)), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: colors.surfaceContainerHighest, + child: Center(child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2))), + ), + ), + // 底部渐变 + 日期 + Positioned( + left: 0, right: 0, bottom: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 2), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.55)], + ), + ), + child: Text( + '$day', + style: TextStyle( + fontSize: 10, + fontWeight: isToday ? FontWeight.w700 : FontWeight.w500, + color: isToday ? const Color(0xFFFFD54F) : Colors.white, + ), + ), + ), + ), + // +N 标记 + if (items.length > 1) + Positioned( + top: 3, right: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(6), + ), + child: Text('+${items.length - 1}', style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: Colors.white)), + ), + ), + ], + ), + ); + } + + // ─── 选中日期的详情 ─── + + Widget _buildSelectedDayDetail(ColorScheme colors) { + final items = _dayItems[_selectedDay] ?? []; + if (items.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + child: Text( + '${_selectedDay!.month}月${_selectedDay!.day}日 暂无记录', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ); + } + + return Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: Row( + children: [ + Text( + '${_selectedDay!.month}月${_selectedDay!.day}日', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 6), + Text( + '${items.length}条记录', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ], + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant), + itemBuilder: (_, i) { + final item = items[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 40, height: 40, + child: Image( + image: FileImage(File(item.path)), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: colors.surfaceContainerHighest, + child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)), + ), + ), + ), + ), + title: Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: item.type == 'movie' ? const Color(0xFF4A90D9).withValues(alpha: 0.1) : const Color(0xFF7E57C2).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.type == 'movie' ? '影视' : '书籍', + style: TextStyle(fontSize: 11, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)), + ), + ), + onTap: () { + if (item.type == 'movie') { + Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + } else { + Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + } + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _showAddMenu(BuildContext context) { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text('添加记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), + const SizedBox(height: 8), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.movie_outlined, size: 20, color: const Color(0xFF4A90D9))), + title: Text('添加影视', style: TextStyle(fontSize: 14, color: colors.onSurface)), + subtitle: Text('记录一部影视作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage())); + }, + ), + Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.menu_book_outlined, size: 20, color: const Color(0xFF7E57C2))), + title: Text('添加书籍', style: TextStyle(fontSize: 14, color: colors.onSurface)), + subtitle: Text('记录一本书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage())); + }, + ), + const SizedBox(height: 12), + ]), + ), + ); + } +} + +class _CalendarItem { + final String path; + final String title; + final String type; + final dynamic data; + + _CalendarItem({ + required this.path, + required this.title, + required this.type, + required this.data, + }); +} diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index a9728e5..093110b 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -35,6 +35,8 @@ class _MovieDetailPageState extends State { final ValueNotifier _draggingPoster = ValueNotifier(false); final GlobalKey _posterImageKey = GlobalKey(); double _posterImageHeight = 0.0; + final ValueNotifier _showTitle = ValueNotifier(false); + ScrollController? _overlayScrollController; @override void initState() { @@ -48,6 +50,8 @@ class _MovieDetailPageState extends State { void dispose() { _posterOffset.dispose(); _draggingPoster.dispose(); + _showTitle.dispose(); + _overlayScrollController?.dispose(); super.dispose(); } @@ -75,25 +79,20 @@ class _MovieDetailPageState extends State { backgroundColor: colors.surface, body: Stack( children: [ - // 图片从导航栏下方开始,固定在顶部 - Column( - children: [ - SizedBox(height: topSafe + 48), - SizedBox( - height: 320, - width: double.infinity, - child: _buildPosterSection(movie), - ), - ], - ), - // 可滚动内容区域从图片底部开始 - Positioned( - top: topSafe + 48 + 320, - left: 0, right: 0, bottom: 0, + // 整体可滚动(图片 + 内容一起滑动) + Padding( + padding: EdgeInsets.only(top: topSafe + 48), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 封面图 + SizedBox( + height: 320, + width: double.infinity, + child: _buildPosterSection(movie), + ), + // 详细信息 _buildBasicInfo(movie), Divider(height: 0.5, thickness: 0.5, color: colors.outline), if (movie.directors.isNotEmpty) @@ -154,6 +153,12 @@ class _MovieDetailPageState extends State { final screenH = MediaQuery.of(context).size.height; final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; + // 初始化滚动控制器(只创建一次) + _overlayScrollController ??= ScrollController()..addListener(() { + final show = (_overlayScrollController?.offset ?? 0) > 10; + if (_showTitle.value != show) _showTitle.value = show; + }); + return Scaffold( body: Stack( children: [ @@ -180,6 +185,7 @@ class _MovieDetailPageState extends State { // 内容 SafeArea( child: Column(children: [ + // 顶部栏:只在滚动后显示标题 SizedBox( height: 48, child: Row(children: [ @@ -188,15 +194,26 @@ class _MovieDetailPageState extends State { icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), onPressed: () => Navigator.pop(context), ), - const Spacer(), - Text(movie.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), - maxLines: 1, overflow: TextOverflow.ellipsis), + ValueListenableBuilder( + valueListenable: _showTitle, + builder: (_, show, __) => AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), + child: Text(movie.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + ), const Spacer(), _buildStyleButton(), ]), ), Expanded( child: SingleChildScrollView( + controller: _overlayScrollController, padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildOverlayHeader(movie), @@ -298,33 +315,34 @@ class _MovieDetailPageState extends State { void _showStylePicker() { final colors = Theme.of(context).colorScheme; const names = ['默认样式', '毛玻璃层叠']; + const icons = [Icons.article_outlined, Icons.blur_on_outlined]; + const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片']; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), - const SizedBox(height: 16), - Wrap(spacing: 10, runSpacing: 10, children: List.generate(names.length, (i) { - final selected = _detailStyle == i; - return GestureDetector( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 12), + for (int i = 0; i < names.length; i++) ...[ + if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), + subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: _detailStyle == i + ? Icon(Icons.check_circle, size: 20, color: colors.primary) + : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5), - ), - child: Text(names[i], style: TextStyle( - fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6), - )), - ), - ); - })), + ), + ], + const SizedBox(height: 12), ]), ), ); diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index f7dbb3c..cd9f711 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -11,6 +13,14 @@ import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../widgets/genre_selector_page.dart'; +import '../../widgets/text_input_panel.dart'; + +/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用) +List _collectUnique(List> lists) { + final s = {}; + for (final l in lists) { s.addAll(l); } + return s.toList()..sort(); +} /// 添加/编辑影视页面 - 紧凑双行布局设计 class MovieFormPage extends StatefulWidget { @@ -32,9 +42,6 @@ class _MovieFormPageState extends State { late TextEditingController _summaryController; late TextEditingController _ratingController; - // 多值字段的临时输入控制器 - final Map _tagControllers = {}; - // 数据 List _directors = []; List _writers = []; @@ -45,6 +52,7 @@ class _MovieFormPageState extends State { String _status = 'want_to_watch'; DateTime? _releaseDate; DateTime? _watchDate; + bool _isDownloading = false; @override void initState() { @@ -91,15 +99,9 @@ class _MovieFormPageState extends State { _titleController.dispose(); _summaryController.dispose(); _ratingController.dispose(); - _tagControllers.values.forEach((c) => c.dispose()); - // 最后调用 super.dispose super.dispose(); } - TextEditingController _getTagController(String key) { - return _tagControllers.putIfAbsent(key, () => TextEditingController()); - } - /// 构建右上角操作按钮 Widget _buildActionButton({ required IconData icon, @@ -332,52 +334,35 @@ class _MovieFormPageState extends State { /// 从URL下载封面图 Future _downloadCoverFromUrl(String url) async { + setState(() => _isDownloading = true); try { - // 下载网络图片,添加请求头模拟浏览器 final response = await http.get( Uri.parse(url), headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - 'Referer': 'https://movie.douban.com/', + 'Referer': Uri.parse(url).replace(path: '/').toString(), }, ); - if (response.statusCode != 200) { - throw Exception('下载失败: HTTP ${response.statusCode}'); - } + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); - // 检查内容类型 final contentType = response.headers['content-type']; - if (contentType != null && !contentType.startsWith('image/')) { - throw Exception('链接返回的不是图片'); - } + if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); - // 检查文件大小(最大 10MB) - if (response.bodyBytes.length > 10 * 1024 * 1024) { - throw Exception('图片太大'); - } - - // 生成文件名 final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - // 如果是编辑模式,使用现有影视ID;如果是新建模式,使用临时ID final movieId = widget.movie?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); - - // 保存到新的路径结构 - final targetPath = await ImagePathHelper.instance.getMoviePosterPath( - movieId, - fileName - ); + final targetPath = await ImagePathHelper.instance.getMoviePosterPath(movieId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); - - // 写入文件 await File(targetPath).writeAsBytes(response.bodyBytes); setState(() => _posterPath = targetPath); } catch (e) { - // 封面下载失败不影响其他信息填充 debugPrint('封面下载失败: $e'); + if (mounted) ToastUtil.show(context, '下载失败: $e'); + } finally { + if (mounted) setState(() => _isDownloading = false); } } @@ -422,17 +407,12 @@ class _MovieFormPageState extends State { // 封面选择 - 居中显示 Center(child: _buildCoverPicker()), - const SizedBox(height: 32), - - // 状态选择(靠左显示) - _buildStatusSelector(), - const SizedBox(height: 20), - // 评分 - 星星选择(靠左显示) - _buildStarRating(), + // 状态 + 评分(合并为一行) + _buildStatusRatingRow(), - const SizedBox(height: 32), + const SizedBox(height: 24), // 信息卡片网格 Wrap( @@ -448,12 +428,15 @@ class _MovieFormPageState extends State { value: _titleController.text, required: true, icon: Icons.movie_outlined, - onTap: () => _showTextInputDialog( - title: '影视名称', - initialValue: _titleController.text, - hint: '请输入影视名称', - onConfirm: (value) => setState(() => _titleController.text = value), - ), + onTap: () async { + final result = await TextInputPanel.show( + context: context, + title: '影视名称', + initialValue: _titleController.text, + hint: '请输入影视名称', + ); + if (result != null) setState(() => _titleController.text = result); + }, ), ), SizedBox( @@ -466,12 +449,16 @@ class _MovieFormPageState extends State { : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', icon: Icons.alternate_email_outlined, scrollable: true, - onTap: () => _showMultiValueDialog( - title: '添加别名', - initialValues: _alternateTitles, - hint: '输入别名', - onConfirm: (values) => setState(() => _alternateTitles = values), - ), + onTap: () async { + final result = await GenreSelectorPage.show( + context: context, + title: '添加别名', + existingTags: [], + initialSelected: _alternateTitles, + hint: '输入别名', + ); + if (result != null) setState(() => _alternateTitles = result); + }, ), ), @@ -485,12 +472,18 @@ class _MovieFormPageState extends State { ? '' : '${_directors.length}人:${_directors.join('、')}', icon: Icons.videocam_outlined, - onTap: () => _showMultiValueDialog( - title: '添加导演', - initialValues: _directors, - hint: '输入导演姓名', - onConfirm: (values) => setState(() => _directors = values), - ), + onTap: () async { + final provider = context.read(); + final data = provider.movies.map((m) => m.directors).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择导演', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _directors, + hint: '如:张艺谋、李安', + ); + if (result != null) setState(() => _directors = result); + }, ), ), SizedBox( @@ -502,12 +495,18 @@ class _MovieFormPageState extends State { ? '' : '${_writers.length}人:${_writers.join('、')}', icon: Icons.edit_note_outlined, - onTap: () => _showMultiValueDialog( - title: '添加编剧', - initialValues: _writers, - hint: '输入编剧姓名', - onConfirm: (values) => setState(() => _writers = values), - ), + onTap: () async { + final provider = context.read(); + final data = provider.movies.map((m) => m.writers).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择编剧', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _writers, + hint: '如:刘慈欣、王家卫', + ); + if (result != null) setState(() => _writers = result); + }, ), ), @@ -521,12 +520,18 @@ class _MovieFormPageState extends State { ? '' : '${_actors.length}人:${_actors.join('、')}', icon: Icons.people_outline, - onTap: () => _showMultiValueDialog( - title: '添加主演', - initialValues: _actors, - hint: '输入主演姓名', - onConfirm: (values) => setState(() => _actors = values), - ), + onTap: () async { + final provider = context.read(); + final data = provider.movies.map((m) => m.actors).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择主演', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _actors, + hint: '如:梁朝伟、周星驰', + ); + if (result != null) setState(() => _actors = result); + }, ), ), SizedBox( @@ -542,20 +547,15 @@ class _MovieFormPageState extends State { final provider = context.read(); final tags = await provider.getTags('movie_genre', excludeHidden: true); final existingNames = tags.map((t) => t['name'] as String).toList(); - if (mounted) { - final result = await Navigator.push>( - context, - MaterialPageRoute( - builder: (_) => GenreSelectorPage( - title: '选择类型', - existingTags: existingNames, - initialSelected: _genres, - hint: '如:剧情、科幻、悬疑', - ), - ), - ); - if (result != null) setState(() => _genres = result); - } + if (!mounted) return; + final result = await GenreSelectorPage.show( + context: context, + title: '选择类型', + existingTags: existingNames, + initialSelected: _genres, + hint: '如:剧情、科幻、悬疑', + ); + if (result != null) setState(() => _genres = result); }, ), ), @@ -576,32 +576,19 @@ class _MovieFormPageState extends State { SizedBox( width: (MediaQuery.of(context).size.width - 52) / 2, height: 90, - child: Stack( - children: [ - _buildInfoCard( - label: '观看日期', - value: _watchDate != null - ? '${_watchDate!.year}.${_watchDate!.month.toString().padLeft(2, '0')}.${_watchDate!.day.toString().padLeft(2, '0')}' - : '', - icon: Icons.visibility_outlined, - onTap: () => _selectWatchDate(), - ), - if (_watchDate != null) - Positioned( - top: 8, right: 8, - child: GestureDetector( + child: _buildInfoCard( + label: '观看日期', + value: _watchDate != null + ? '${_watchDate!.year}.${_watchDate!.month.toString().padLeft(2, '0')}.${_watchDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.visibility_outlined, + trailing: _watchDate != null + ? GestureDetector( onTap: () => setState(() => _watchDate = null), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - ), - ], + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ) + : null, + onTap: () => _selectWatchDate(), ), ), @@ -633,6 +620,7 @@ class _MovieFormPageState extends State { required VoidCallback onTap, bool required = false, IconData? icon, + Widget? trailing, double? height, bool scrollable = false, bool scrollHorizontal = false, @@ -722,6 +710,10 @@ class _MovieFormPageState extends State { fontWeight: required ? FontWeight.w500 : FontWeight.normal, ), ), + if (trailing != null) ...[ + const Spacer(), + trailing, + ], ], ), const SizedBox(height: 8), @@ -746,315 +738,28 @@ class _MovieFormPageState extends State { } } - /// 显示文本输入对话框 - Future _showTextInputDialog({ - required String title, - required String initialValue, - required Function(String) onConfirm, - int maxLines = 1, - String hint = '', - }) async { - final result = await showDialog( - context: context, - builder: (context) => _TextInputDialog( - title: title, - initialValue: initialValue, - hint: hint, - maxLines: maxLines, - ), - ); - - if (result != null) { - onConfirm(result); - } - } - - /// 显示多值输入对话框 - Future _showMultiValueDialog({ - required String title, - required List initialValues, - required Function(List) onConfirm, - String hint = '', - List existingTags = const [], - }) async { - final result = await showDialog>( - context: context, - builder: (context) => _MultiValueDialog( - title: title, - initialValues: initialValues, - hint: hint, - existingTags: existingTags, - ), - ); - - if (result != null) { - onConfirm(result); - } - } - - /// 构建纵向日期条目(标签在上,日期在下) - Widget _buildVerticalDateItem({ - required String label, - required DateTime? date, - required VoidCallback onTap, - required VoidCallback onClear, - }) { + /// 构建状态 + 评分合并行 + Widget _buildStatusRatingRow() { final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: onTap, - child: Row( + final currentRating = double.tryParse(_ratingController.text) ?? 0; + final starRating = currentRating / 2; + final hasRating = _ratingController.text.isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Column( + children: [ + // 状态 + Row( children: [ - Expanded( - child: Text( - date != null - ? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}' - : '选择日期', - style: TextStyle( - fontSize: 15, - color: date != null - ? colors.onSurface - : colors.onSurface.withValues(alpha: 0.25), - ), - ), - ), - if (date != null) - GestureDetector( - onTap: onClear, - child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ], - ), - ), - ], - ); - } - - /// 构建卡片式表单条目(带边框背景) - Widget _buildCardFormItem({ - required String label, - required Widget child, - bool required = false, - }) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - required ? '$label *' : label, - style: TextStyle( - fontSize: 13, - color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), - fontWeight: required ? FontWeight.w500 : FontWeight.normal, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.onSurface.withValues(alpha: 0.2)), - ), - child: child, - ), - ], - ); - } - - /// 构建卡片式日期选择项 - Widget _buildCardDateItem({ - required String label, - required DateTime? date, - required VoidCallback onTap, - required VoidCallback onClear, - }) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.onSurface.withValues(alpha: 0.2)), - ), - child: Row( - children: [ - Icon( - Icons.calendar_today_outlined, - size: 18, - color: date != null ? colors.onSurface : colors.onSurface.withValues(alpha: 0.35), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - date != null - ? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}' - : '选择日期', - style: TextStyle( - fontSize: 15, - color: date != null - ? colors.onSurface - : colors.onSurface.withValues(alpha: 0.35), - ), - ), - ), - if (date != null) - GestureDetector( - onTap: onClear, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(4), - ), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - ], - ), - ), - ), - ], - ); - } - - /// 构建表单条目(标签 + 内容)- 剧情简介使用 - Widget _buildFormItem({required String label, required Widget child}) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), - ), - const SizedBox(height: 8), - child, - ], - ); - } - - /// 构建横向表单条目(标签在左,内容在右) - Widget _buildHorizontalFormItem({ - required String label, - required Widget child, - bool required = false, - }) { - final colors = Theme.of(context).colorScheme; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 48, - child: Text( - required ? '$label *' : label, - style: TextStyle( - fontSize: 13, - color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), - fontWeight: required ? FontWeight.w500 : FontWeight.normal, - ), - ), - ), - const SizedBox(width: 16), - Expanded(child: child), - ], - ); - } - - /// 构建纵向多值条目(标签在上,输入框在下,带添加按钮) - Widget _buildHorizontalMultiValueItem({ - required String label, - required List values, - required String hint, - required String controllerKey, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - final controller = _getTagController(controllerKey); - final colors = Theme.of(context).colorScheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 标签行:标签 + 添加按钮 - Row( - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const Spacer(), - // 添加按钮 - ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, child) { - final hasText = value.text.trim().isNotEmpty; - return GestureDetector( - onTap: hasText - ? () { - final text = controller.text.trim(); - if (text.isNotEmpty && !values.contains(text)) { - onAdd(text); - controller.clear(); - } - } - : null, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: hasText ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add, - size: 14, - color: hasText ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(width: 4), - Text( - '添加', - style: TextStyle( - fontSize: 12, - color: hasText ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.25), - ), - ), - ], - ), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - // 已选标签 - if (values.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + Text('状态', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), @@ -1062,315 +767,71 @@ class _MovieFormPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - entry.value, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), + _buildStatusOption('想看', 'want_to_watch'), + _buildStatusOption('在看', 'watching'), + _buildStatusOption('已看', 'watched'), ], ), - ); - }).toList(), + ), + ], ), - if (values.isNotEmpty) const SizedBox(height: 8), - // 输入框(带边框背景) - Container( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.onSurface.withValues(alpha: 0.2)), - ), - child: TextField( - controller: controller, - style: TextStyle(fontSize: 14, color: colors.onSurface), - decoration: InputDecoration( - hintText: hint, - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: (value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - onAdd(trimmed); - controller.clear(); - } - }, - ), - ), - ], - ); - } - - /// 构建多值条目 - Widget _buildMultiValueItem({ - required String label, - required List values, - required String hint, - required String controllerKey, - required Function(String) onAdd, - required Function(int) onRemove, - }) { - final controller = _getTagController(controllerKey); - final colors = Theme.of(context).colorScheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一行:标签 + 添加按钮 - Row( - children: [ - Text( - label, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), - ), - const Spacer(), - // 添加按钮(当输入框有内容时显示) - ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, child) { - final hasText = value.text.trim().isNotEmpty; + const SizedBox(height: 12), + // 评分 + Row( + children: [ + Text('评分', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + // 星星 + ...List.generate(5, (index) { + final starValue = index + 1; + final isFilled = starValue <= starRating; + final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; return GestureDetector( - onTap: hasText - ? () { - final text = controller.text.trim(); - if (text.isNotEmpty && !values.contains(text)) { - onAdd(text); - controller.clear(); - } - } - : null, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: hasText ? colors.primary : colors.outline, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add, - size: 14, - color: hasText ? colors.primary : colors.onSurface.withValues(alpha: 0.25), - ), - const SizedBox(width: 2), - Text( - '添加', - style: TextStyle( - fontSize: 12, - color: hasText ? colors.primary : colors.onSurface.withValues(alpha: 0.25), - ), - ), - ], + onTap: () => setState(() => _ratingController.text = (starValue * 2).toString()), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Icon( + isHalf ? Icons.star_half : (isFilled ? Icons.star : Icons.star_border), + size: 22, + color: (isFilled || isHalf) ? const Color(0xFFFFB800) : colors.outline, ), ), ); - }, - ), - ], - ), - const SizedBox(height: 8), - // 第二行:已选标签 + 输入框 - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ...values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + }), + const SizedBox(width: 8), + // 输入框 + Container( + width: 48, height: 28, decoration: BoxDecoration( color: colors.surfaceContainerHighest, - border: Border.all(color: colors.outline), + borderRadius: BorderRadius.circular(6), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: TextStyle(fontSize: 14, color: colors.onSurface), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ], + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + inputFormatters: [_RatingInputFormatter()], + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), + decoration: InputDecoration( + hintText: '0-10', + hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 6), + isDense: true, + ), + onChanged: (_) => setState(() {}), ), - ); - }), - // 输入框 - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 100, maxWidth: 150), - child: TextField( - controller: controller, - style: TextStyle(fontSize: 14, color: colors.onSurface), - decoration: InputDecoration( - hintText: values.isEmpty ? hint : '', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 5), - ), - onSubmitted: (value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - onAdd(trimmed); - controller.clear(); - } - }, ), - ), - ], - ), - ], - ); - } - - /// 构建状态选择器(简洁风格) - Widget _buildStatusSelector() { - final colors = Theme.of(context).colorScheme; - return Row( - children: [ - Text( - '状态', - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(width: 16), - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildStatusOption('想看', 'want_to_watch'), - _buildStatusOption('在看', 'watching'), - _buildStatusOption('已看', 'watched'), + if (hasRating) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _ratingController.clear()), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ], ], ), - ), - ], - ); - } - - /// 构建星星评分(支持手动输入) - Widget _buildStarRating() { - final colors = Theme.of(context).colorScheme; - final hasRating = _ratingController.text.isNotEmpty; - return Row( - children: [ - Text( - '评分', - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(width: 16), - // 星星选择 - _buildStarSelector(), - const SizedBox(width: 12), - // 手动输入框 - _buildRatingInputField(), - // 清除按钮 - if (hasRating) ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () => setState(() => _ratingController.clear()), - child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), - ), ], - ], - ); - } - - /// 构建星星选择器 - Widget _buildStarSelector() { - final currentRating = double.tryParse(_ratingController.text) ?? 0; - final starRating = currentRating / 2; - final colors = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (index) { - final starValue = index + 1; - final scoreValue = starValue * 2; - final isFilled = starValue <= starRating; - final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; - - return InkWell( - onTap: () { - setState(() { - _ratingController.text = scoreValue.toString(); - }); - }, - borderRadius: BorderRadius.circular(4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), - child: Icon( - isHalf - ? Icons.star_half - : isFilled - ? Icons.star - : Icons.star_border, - size: 24, - color: isFilled || isHalf - ? const Color(0xFFFFB800) - : colors.outline, - ), - ), - ); - }), - ), - ); - } - - /// 构建评分输入框(支持0-10,保留1位小数) - Widget _buildRatingInputField() { - final colors = Theme.of(context).colorScheme; - return Container( - width: 56, - height: 32, - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - child: TextFormField( - controller: _ratingController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colors.onSurface, - ), - decoration: InputDecoration( - hintText: '-', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - ), - onChanged: (value) { - if (value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating != null) { - if (rating > 10) { - _ratingController.text = '10.0'; - } else if (rating < 0) { - _ratingController.text = '0.0'; - } - } - } - setState(() {}); - }, ), ); } @@ -1428,12 +889,20 @@ class _MovieFormPageState extends State { borderRadius: BorderRadius.circular(8), ), clipBehavior: Clip.antiAlias, - child: hasPoster - ? FadeInLocalImage( - path: _posterPath, - fit: BoxFit.cover, - ) - : _buildCoverPlaceholder(), + child: Stack( + alignment: Alignment.center, + children: [ + if (hasPoster) + FadeInLocalImage(path: _posterPath, fit: BoxFit.cover) + else + _buildCoverPlaceholder(), + if (_isDownloading) + Container( + color: Colors.black.withValues(alpha: 0.4), + child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ], + ), ), ), // 清空海报按钮(仅当有海报时显示) @@ -1651,117 +1120,61 @@ class _MovieFormPageState extends State { final confirmed = await showDialog( context: context, - builder: (context) { - final colors = Theme.of(context).colorScheme; + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: const Text('添加网络图片'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '请输入图片链接地址', - style: TextStyle( - fontSize: 14, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ), + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), const SizedBox(height: 12), TextField( controller: urlController, - decoration: InputDecoration( - hintText: 'https://movie.douban.com/image.jpg', - hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), - border: const UnderlineInputBorder(), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - ), - style: const TextStyle(fontSize: 14), keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + ), ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text('确定', style: TextStyle(color: colors.onSurface)), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('确定'), ), ], ); }, ); - if (confirmed != true) return; - final url = urlController.text.trim(); - if (url.isEmpty) { - ToastUtil.show(context, '请输入图片链接'); - return; - } + urlController.dispose(); - try { - // 下载网络图片,添加请求头模拟浏览器 - final response = await http.get( - Uri.parse(url), - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', - 'Referer': Uri.parse(url).replace(path: '/').toString(), - }, - ); + if (confirmed != true || url.isEmpty) return; - if (response.statusCode != 200) { - throw Exception('下载失败: HTTP ${response.statusCode}'); - } - - // 检查内容类型 - final contentType = response.headers['content-type']; - if (contentType != null && !contentType.startsWith('image/')) { - throw Exception('链接返回的不是图片,可能是网页或需要登录'); - } - - // 检查文件大小(最大 10MB) - if (response.bodyBytes.length > 10 * 1024 * 1024) { - throw Exception('图片太大,请使用小于 10MB 的图片'); - } - - // 生成文件名 - final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - // 如果是编辑模式,使用现有影视ID;如果是新建模式,使用临时ID - final movieId = widget.movie?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); - - // 保存到新的路径结构 - final targetPath = await ImagePathHelper.instance.getMoviePosterPath( - movieId, - fileName - ); - await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); - - // 写入文件 - await File(targetPath).writeAsBytes(response.bodyBytes); - - setState(() => _posterPath = targetPath); - - if (mounted) { - ToastUtil.show(context, '添加成功'); - } - } catch (e) { - if (mounted) { - ToastUtil.show(context, '添加失败: $e'); - } - } + await _downloadCoverFromUrl(url); } /// 选择上映日期 @@ -1794,59 +1207,6 @@ class _MovieFormPageState extends State { } } - /// 收集所有多值字段输入框中的未提交内容 - void _collectUnsubmittedValues() { - // 别名 - final alternateTitlesController = _tagControllers['alternateTitles']; - if (alternateTitlesController != null) { - final value = alternateTitlesController.text.trim(); - if (value.isNotEmpty && !_alternateTitles.contains(value)) { - _alternateTitles.add(value); - alternateTitlesController.clear(); - } - } - - // 导演 - final directorsController = _tagControllers['directors']; - if (directorsController != null) { - final value = directorsController.text.trim(); - if (value.isNotEmpty && !_directors.contains(value)) { - _directors.add(value); - directorsController.clear(); - } - } - - // 编剧 - final writersController = _tagControllers['writers']; - if (writersController != null) { - final value = writersController.text.trim(); - if (value.isNotEmpty && !_writers.contains(value)) { - _writers.add(value); - writersController.clear(); - } - } - - // 主演 - final actorsController = _tagControllers['actors']; - if (actorsController != null) { - final value = actorsController.text.trim(); - if (value.isNotEmpty && !_actors.contains(value)) { - _actors.add(value); - actorsController.clear(); - } - } - - // 类型 - final genresController = _tagControllers['genres']; - if (genresController != null) { - final value = genresController.text.trim(); - if (value.isNotEmpty && !_genres.contains(value)) { - _genres.add(value); - genresController.clear(); - } - } - } - /// 检查表单是否有内容 bool _hasContent() { if (widget.movie != null) return true; // 编辑模式始终需要确认 @@ -1903,9 +1263,6 @@ class _MovieFormPageState extends State { } try { - // 收集所有多值字段输入框中的未提交内容 - _collectUnsubmittedValues(); - final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; @@ -2014,360 +1371,6 @@ class _MovieFormPageState extends State { } } -/// 多值输入对话框组件 -class _MultiValueDialog extends StatefulWidget { - final String title; - final List initialValues; - final String hint; - final List existingTags; - - const _MultiValueDialog({ - required this.title, - required this.initialValues, - required this.hint, - this.existingTags = const [], - }); - - @override - State<_MultiValueDialog> createState() => _MultiValueDialogState(); -} - -class _MultiValueDialogState extends State<_MultiValueDialog> { - late List values; - final controller = TextEditingController(); - - @override - void initState() { - super.initState(); - values = List.from(widget.initialValues); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void _addValue(String value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - setState(() { - values.add(trimmed); - }); - controller.clear(); - } - } - - void _removeValue(int index) { - setState(() { - values.removeAt(index); - }); - } - - void _toggleExistingTag(String tag) { - setState(() { - if (values.contains(tag)) { - values.remove(tag); - } else { - values.add(tag); - } - }); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - // 未选中的已有标签 - final availableTags = widget.existingTags - .where((t) => !values.contains(t)) - .toList(); - - return AlertDialog( - backgroundColor: colors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - widget.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - content: SizedBox( - width: double.maxFinite, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.55, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 可滚动的标签区域 - if (values.isNotEmpty || availableTags.isNotEmpty) - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 已选择的标签 - if (values.isNotEmpty) ...[ - Text( - '已选择', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: values.map((v) { - final display = v.length > 8 ? '${v.substring(0, 8)}...' : v; - return Container( - padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - display, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colors.onPrimary, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () { - setState(() => values.remove(v)); - }, - child: Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: colors.onPrimary.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.close, size: 12, color: colors.onPrimary), - ), - ), - ], - ), - ); - }).toList(), - ), - if (availableTags.isNotEmpty) ...[ - const SizedBox(height: 16), - Divider(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 16), - ], - ], - // 已有类型 - if (availableTags.isNotEmpty) ...[ - Text( - '已有类型', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: availableTags.map((tag) { - final display = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; - return GestureDetector( - onTap: () { - setState(() => values.add(tag)); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: colors.outline, - width: 0.5, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 4), - Text( - display, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colors.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ), - ), - // 固定底部的输入框 - if (values.isNotEmpty || availableTags.isNotEmpty) ...[ - const SizedBox(height: 12), - Divider(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 12), - ], - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - style: TextStyle(fontSize: 15, color: colors.onSurface), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: _addValue, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _addValue(controller.text), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.add, size: 20, color: colors.onPrimary), - ), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))), - ), - ElevatedButton( - onPressed: () { - _addValue(controller.text); - Navigator.pop(context, values); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('确定'), - ), - ], - ); - } -} - -/// 文本输入对话框组件 -class _TextInputDialog extends StatefulWidget { - final String title; - final String initialValue; - final String hint; - final int maxLines; - - const _TextInputDialog({ - required this.title, - required this.initialValue, - required this.hint, - required this.maxLines, - }); - - @override - State<_TextInputDialog> createState() => _TextInputDialogState(); -} - -class _TextInputDialogState extends State<_TextInputDialog> { - late final TextEditingController controller; - - @override - void initState() { - super.initState(); - controller = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return AlertDialog( - backgroundColor: colors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - widget.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - content: TextField( - controller: controller, - maxLines: widget.maxLines, - style: TextStyle(fontSize: 15, color: colors.onSurface), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('确定'), - ), - ], - ); - } -} - /// 剧情简介全屏编辑页 class _SummaryEditorPage extends StatefulWidget { final String initialText; @@ -2425,3 +1428,16 @@ class _SummaryEditorPageState extends State<_SummaryEditorPage> { ); } } + +/// 评分输入格式化器:只允许 0-10,最多1位小数 +class _RatingInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + if (!RegExp(r'^\d{0,2}\.?\d{0,1}$').hasMatch(text)) return oldValue; + final n = double.tryParse(text); + if (n != null && n > 10) return oldValue; + return newValue; + } +} diff --git a/lib/pages/movies/movie_share_page.dart b/lib/pages/movies/movie_share_page.dart index 24e9af7..2b49057 100644 --- a/lib/pages/movies/movie_share_page.dart +++ b/lib/pages/movies/movie_share_page.dart @@ -126,31 +126,34 @@ class _MovieSharePageState extends State { void _showStylePicker() { final colors = Theme.of(context).colorScheme; + const icons = [Icons.image_outlined, Icons.confirmation_num_outlined, Icons.local_movies_outlined, Icons.card_giftcard_outlined]; + const subtitles = ['简约海报风格', '经典复古票根', '电影票票根样式', '高端收藏版票根']; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), - const SizedBox(height: 16), - Wrap(spacing: 10, runSpacing: 10, children: List.generate(_styleNames.length, (i) { - final selected = _currentStyle == i; - return GestureDetector( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align(alignment: Alignment.centerLeft, child: Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 12), + for (int i = 0; i < _styleNames.length; i++) ...[ + if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], size: 20, color: _currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(_styleNames[i], style: TextStyle(fontSize: 13, fontWeight: _currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), + subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: _currentStyle == i + ? Icon(Icons.check_circle, size: 20, color: colors.primary) + : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5), - ), - child: Text(_styleNames[i], style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6))), - ), - ); - })), + ), + ], + const SizedBox(height: 12), ]), ), ); diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index b504842..c18ba8c 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; +import '../../utils/user_prefs.dart'; import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; @@ -21,10 +22,8 @@ class _MovieTabPageState extends State { bool _hasMore = true; bool _isLoading = false; int _offset = 0; - int _lastStatusIndex = -1; bool _initialized = false; - int _lastDataCount = -1; - DateTime? _lastUpdatedAt; + int _lastStatusIndex = -1; late ScrollController _scrollController; AppProvider? _provider; int _lastScrollSignal = 0; @@ -39,8 +38,6 @@ class _MovieTabPageState extends State { final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); - _lastDataCount = provider.movies.length; - if (provider.movies.isNotEmpty) _lastUpdatedAt = provider.movies.first.updatedAt; _loadFirst(); }); } @@ -64,15 +61,8 @@ class _MovieTabPageState extends State { } } - final count = provider.movies.length; - final latest = provider.movies.isNotEmpty ? provider.movies.first.updatedAt : null; - if (count != _lastDataCount || latest != _lastUpdatedAt) { - _lastDataCount = count; - _lastUpdatedAt = latest; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadFirst(); - }); - } + // 数据变化时刷新列表(排序/评分/新增等) + _loadFirst(); } void _onScroll() { @@ -90,7 +80,7 @@ class _MovieTabPageState extends State { _initialized = true; final status = _statusMap[statusIdx] ?? 'watched'; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadMoviesPaged(status: status, offset: 0); + final list = await provider.loadMoviesPaged(status: status, offset: 0, sortMode: UserPrefs().movieSortMode); if (!mounted) return; setState(() { _items.clear(); @@ -106,7 +96,7 @@ class _MovieTabPageState extends State { setState(() => _isLoading = true); final provider = context.read(); final status = _statusMap[provider.movieStatusIndex] ?? 'watched'; - final list = await provider.loadMoviesPaged(status: status, offset: _offset); + final list = await provider.loadMoviesPaged(status: status, offset: _offset, sortMode: UserPrefs().movieSortMode); if (!mounted) return; setState(() { _items.addAll(list); diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index b5dbfad..d07ee5a 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -30,6 +30,7 @@ class _NoteDetailPageState extends State { return Scaffold( backgroundColor: colors.surface, appBar: AppBar( + titleSpacing: 0, title: Text( note.title.isNotEmpty ? note.title diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index 417f9a9..29226f4 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:image_picker/image_picker.dart'; @@ -9,6 +10,7 @@ import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../widgets/fade_in_local_image.dart'; +import '../../widgets/tag_side_panel.dart'; /// 添加/编辑笔记页面 - 极简书写界面 class NoteFormPage extends StatefulWidget { @@ -30,6 +32,9 @@ class _NoteFormPageState extends State { final ImagePicker _picker = ImagePicker(); String? _tempNoteId; // 新建模式时使用的临时笔记ID String _editorMode = 'edit'; // 'edit' | 'preview' + Timer? _autoSaveTimer; + String _saveStatus = ''; // '', 'saved' + Note? _savedNote; // 新建模式首次自动保存后的笔记引用 static const _weekdays = ['一', '二', '三', '四', '五', '六', '日']; @@ -44,15 +49,82 @@ class _NoteFormPageState extends State { _tags = note != null ? List.from(note.tags) : []; _images = note != null ? List.from(note.images) : []; _isEditing = note != null; + _titleController.addListener(_onTextChanged); + _contentController.addListener(_onTextChanged); } @override void dispose() { + _autoSaveTimer?.cancel(); _titleController.dispose(); _contentController.dispose(); super.dispose(); } + void _onTextChanged() { + _autoSaveTimer?.cancel(); + _autoSaveTimer = Timer(const Duration(seconds: 2), () { + if (mounted) _autoSave(); + }); + } + + Future _autoSave() async { + final content = _contentController.text.trim(); + final title = _titleController.text.trim(); + if (title.isEmpty && content.isEmpty) return; + + try { + final now = DateTime.now(); + if (_isEditing) { + final updatedNote = widget.note!.copyWith( + title: title, + content: content, + tags: _tags, + images: _images, + updatedAt: now, + ); + await context.read().updateNote(updatedNote); + } else if (_savedNote != null) { + final updatedNote = _savedNote!.copyWith( + title: title, + content: content, + tags: _tags, + images: _images, + updatedAt: now, + ); + await context.read().updateNote(updatedNote); + _savedNote = updatedNote; + } else { + final noteId = now.millisecondsSinceEpoch.toString(); + List finalImages = []; + if (_images.isNotEmpty) { + final oldNoteId = _tempNoteId ?? noteId; + finalImages = await _moveImagesToNewId(oldNoteId, noteId); + } + final newNote = Note( + id: noteId, + title: title, + content: content, + tags: _tags, + images: finalImages.isNotEmpty ? finalImages : _images, + createdAt: _createdAt, + updatedAt: now, + ); + await context.read().addNote(newNote); + _savedNote = newNote; + _isEditing = true; + } + if (mounted) { + setState(() => _saveStatus = 'saved'); + Timer(const Duration(seconds: 3), () { + if (mounted && _saveStatus == 'saved') setState(() => _saveStatus = ''); + }); + } + } catch (_) { + // 自动保存失败静默处理 + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -149,6 +221,19 @@ class _NoteFormPageState extends State { style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface), ), ), + if (_saveStatus == 'saved') + Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 6, height: 6, + decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle)), + const SizedBox(width: 4), + Text('已保存', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), GestureDetector( onTap: _saveNote, child: Container( @@ -501,7 +586,7 @@ class _NoteFormPageState extends State { Widget _buildAddTagButton() { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: _showAddTagDialog, + onTap: _showTagPanel, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -520,184 +605,26 @@ class _NoteFormPageState extends State { ); } - /// 显示添加标签对话框 - Future _showAddTagDialog() async { - final controller = TextEditingController(); - - // 从 tags 表获取已有标签 + /// 显示标签侧边面板 + Future _showTagPanel() async { final provider = context.read(); final tagRows = await provider.getTags('note_tag'); final allTags = tagRows.map((t) => t['name'] as String).toSet(); - // 也从当前笔记内容中收集 for (final note in provider.notes) { allTags.addAll(note.tags); } - // 过滤掉已添加的标签 - final availableTags = allTags.where((tag) => !_tags.contains(tag)).toList()..sort(); - showDialog( + if (!mounted) return; + TagSidePanel.show( context: context, - builder: (ctx) { - final colors = Theme.of(context).colorScheme; - return StatefulBuilder( - builder: (ctx, setDialogState) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), - title: Text( - '添加标签', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 输入框 - TextField( - controller: controller, - autofocus: true, - style: TextStyle(fontSize: 14, color: colors.onSurface), - cursorColor: colors.primary, - decoration: InputDecoration( - hintText: '输入新标签名称', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), - filled: true, - fillColor: colors.surfaceContainerHigh, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: colors.primary, width: 1), - ), - suffixIcon: controller.text.isNotEmpty - ? IconButton( - icon: Icon(Icons.clear, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), - onPressed: () => controller.clear(), - ) - : null, - ), - onChanged: (_) => setDialogState(() {}), - onSubmitted: (value) { - _addTag(value); - controller.clear(); - setDialogState(() {}); - }, - ), - - // 已有标签列表 - if (availableTags.isNotEmpty) ...[ - const SizedBox(height: 20), - Text( - '或选择已有标签', - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 180), - child: SingleChildScrollView( - child: Wrap( - spacing: 8, - runSpacing: 8, - children: availableTags.map((tag) { - return InkWell( - onTap: () { - _addTag(tag); - Navigator.pop(ctx); - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colors.outline, width: 0.5), - ), - child: Text( - tag, - style: TextStyle( - fontSize: 13, - color: colors.onSurface.withValues(alpha: 0.7), - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ], - - if (availableTags.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Center( - child: Text( - '暂无已有标签', - style: TextStyle(fontSize: 13, color: Colors.grey[400]), - ), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom( - foregroundColor: colors.onSurface.withValues(alpha: 0.4), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('取消', style: TextStyle(fontSize: 14)), - ), - ElevatedButton( - onPressed: () { - _addTag(controller.text); - Navigator.pop(ctx); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - ), - child: const Text('添加', style: TextStyle(fontSize: 14)), - ), - ], - actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - ), - ); + selectedTags: List.from(_tags), + allAvailableTags: allTags.toList()..sort(), + onTagsChanged: (newTags) { + setState(() => _tags = newTags); }, ); } - /// 获取所有已有标签(从所有笔记中收集) - /// 添加标签 - void _addTag(String tag) { - final trimmed = tag.trim(); - if (trimmed.isNotEmpty && !_tags.contains(trimmed)) { - setState(() => _tags.add(trimmed)); - } - } - /// 标题输入行 Widget _buildTitleInput(ColorScheme colors) { return Padding( @@ -729,42 +656,15 @@ class _NoteFormPageState extends State { return false; } - /// 离开确认 + /// 离开确认(自动保存后直接返回) Future _confirmLeave() async { - if (!_hasContent()) return true; - final colors = Theme.of(context).colorScheme; - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('当前内容未保存,确定要离开吗?', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: colors.error, - foregroundColor: colors.onError, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('离开'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ); - return result ?? false; + _autoSaveTimer?.cancel(); + if (_hasContent()) await _autoSave(); + return true; } + Future _saveNote() async { + _autoSaveTimer?.cancel(); final content = _contentController.text.trim(); final title = _titleController.text.trim(); diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index fd9b00e..b505cad 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -24,10 +24,7 @@ class _NoteTabPageState extends State { AppProvider? _provider; int _layoutStyle = 0; bool _initialized = false; - int _lastDataCount = -1; - DateTime? _lastUpdatedAt; int _lastScrollSignal = 0; - String? _selectedTag; @override void initState() { @@ -38,8 +35,6 @@ class _NoteTabPageState extends State { final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); - _lastDataCount = provider.notes.length; - if (provider.notes.isNotEmpty) _lastUpdatedAt = provider.notes.first.updatedAt; _loadFirst(); }); } @@ -55,7 +50,6 @@ class _NoteTabPageState extends State { if (!_initialized || !mounted) return; final provider = context.read(); - // 检查回到顶部信号 if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) { _lastScrollSignal = provider.scrollToTopSignal; if (_scrollController.hasClients) { @@ -63,15 +57,8 @@ class _NoteTabPageState extends State { } } - final count = provider.notes.length; - final latest = provider.notes.isNotEmpty ? provider.notes.first.updatedAt : null; - if (count != _lastDataCount || latest != _lastUpdatedAt) { - _lastDataCount = count; - _lastUpdatedAt = latest; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadFirst(); - }); - } + // 数据变化时刷新列表(排序/评分/新增等) + _loadFirst(); } void _onScroll() { @@ -83,12 +70,8 @@ class _NoteTabPageState extends State { Future _loadFirst() async { _initialized = true; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - List list = await context.read().loadNotesPaged(offset: 0); - if (_selectedTag != null) { - final all = context.read().notes.where((n) => !n.isDeleted && n.tags.contains(_selectedTag)).toList() - ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - list = all.take(20).toList(); - } + final sortMode = UserPrefs().noteSortMode; + final list = await context.read().loadNotesPaged(offset: 0, sortMode: sortMode); if (!mounted) return; setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); } @@ -96,14 +79,8 @@ class _NoteTabPageState extends State { Future _loadMore() async { if (_isLoading || !_hasMore) return; setState(() => _isLoading = true); - List list; - if (_selectedTag != null) { - final all = context.read().notes.where((n) => !n.isDeleted && n.tags.contains(_selectedTag)).toList() - ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - list = all.skip(_offset).take(20).toList(); - } else { - list = await context.read().loadNotesPaged(offset: _offset); - } + final sortMode = UserPrefs().noteSortMode; + final list = await context.read().loadNotesPaged(offset: _offset, sortMode: sortMode); if (!mounted) return; setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); } @@ -118,20 +95,11 @@ class _NoteTabPageState extends State { final colors = Theme.of(context).colorScheme; return Consumer(builder: (context, provider, _) { if (_items.isEmpty && _isLoading) return _buildSkeleton(); - if (_items.isEmpty && _selectedTag == null) { + if (_items.isEmpty) { return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context)])); } - return Column( - children: [ - _buildTagBar(provider), - Expanded( - child: _items.isEmpty - ? Center(child: Text('没有"$_selectedTag"相关的笔记', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))) - : _buildContent(), - ), - ], - ); + return _buildContent(); }); } @@ -141,53 +109,6 @@ class _NoteTabPageState extends State { return _buildListView(); } - Widget _buildTagBar(AppProvider provider) { - final colors = Theme.of(context).colorScheme; - final allTags = {}; - for (final n in provider.notes.where((n) => !n.isDeleted)) { - allTags.addAll(n.tags); - } - if (allTags.isEmpty) return const SizedBox.shrink(); - final tags = allTags.toList()..sort(); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), - ), - child: SizedBox( - height: 32, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: tags.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (_, i) { - final tag = tags[i]; - final selected = _selectedTag == tag; - return GestureDetector( - onTap: () { - setState(() => _selectedTag = selected ? null : tag); - _loadFirst(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - alignment: Alignment.center, - child: Text(tag, style: TextStyle( - fontSize: 12, - color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6), - )), - ), - ); - }, - ), - ), - ); - } - Widget _buildSkeleton() { switch (_layoutStyle) { case 1: return _buildWaterfallSkeleton(); @@ -246,7 +167,7 @@ class _NoteTabPageState extends State { final colors = Theme.of(context).colorScheme; return GestureDetector( onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()), - onLongPress: () => _showDeleteDialog(context, note), + onLongPress: () => _showNoteActions(note), child: IntrinsicHeight(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(width: 40, child: Column(children: [ Container(width: 10, height: 10, @@ -256,7 +177,10 @@ class _NoteTabPageState extends State { Expanded(child: Container(margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(_formatFullDate(note.updatedAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + Row(children: [ + Expanded(child: Text(_formatFullDate(note.updatedAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)))), + if (note.isPinned) Icon(Icons.push_pin, size: 14, color: colors.primary), + ]), if (note.title.isNotEmpty) ...[const SizedBox(height: 6), Text(note.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), ], @@ -305,7 +229,7 @@ class _NoteTabPageState extends State { final extraCount = images.length - 1; return GestureDetector( onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()), - onLongPress: () => _showDeleteDialog(context, note), + onLongPress: () => _showNoteActions(note), child: Container(margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(10), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))]), @@ -344,9 +268,7 @@ class _NoteTabPageState extends State { ]), ), ); - } - - String _getPreviewText(Note note) { + } String _getPreviewText(Note note) { final text = note.content.replaceAll(RegExp(r'[#*\[\]\(\)]'), '').trim(); return text.isEmpty ? '(无内容)' : text; } @@ -368,23 +290,72 @@ class _NoteTabPageState extends State { ); } - void _showDeleteDialog(BuildContext context, Note note) { + void _showNoteActions(Note note) { final colors = Theme.of(context).colorScheme; - showDialog(context: context, builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('确定要删除这条笔记吗?删除后可在回收站恢复。', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), - ElevatedButton(onPressed: () async { await context.read().removeNote(note.id); Navigator.pop(ctx); _loadFirst(); }, - style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), - child: const Text('删除'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - )); + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + 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: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(note.isPinned ? Icons.push_pin_outlined : Icons.push_pin, size: 20, color: colors.onSurface.withValues(alpha: 0.6))), + title: Text(note.isPinned ? '取消置顶' : '置顶', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + subtitle: Text(note.isPinned ? '取消置顶后按时间排序' : '置顶后始终显示在最前', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + context.read().toggleNotePin(note.id, !note.isPinned); + }, + ), + Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.delete_outline, size: 20, color: colors.error)), + title: Text('删除', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.error)), + subtitle: Text('删除后可在回收站恢复', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + _showDeleteDialog(note); + }, + ), + const SizedBox(height: 12), + ]), + ), + ); + } + + void _showDeleteDialog(Note note) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除这条笔记吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () async { await context.read().removeNote(note.id); Navigator.pop(ctx); _loadFirst(); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); } Widget _buildEmptyState(BuildContext context) { diff --git a/lib/pages/online_search/online_search_page.dart b/lib/pages/online_search/online_search_page.dart index 8d480af..66907bc 100644 --- a/lib/pages/online_search/online_search_page.dart +++ b/lib/pages/online_search/online_search_page.dart @@ -158,7 +158,9 @@ class _OnlineSearchPageState extends State { } } catch (e) { if (mounted) { - final msg = e.toString().contains('TimeoutException') ? '搜索超时,请稍后重试' : '搜索失败,请检查网络'; + final msg = e.toString().contains('TimeoutException') + ? '搜索超时,请稍后重试' + : '搜索失败,请检查网络'; ToastUtil.show(context, msg); } } @@ -226,7 +228,9 @@ class _OnlineSearchPageState extends State { } } catch (e) { if (mounted) { - final msg = e.toString().contains('TimeoutException') ? '搜索超时,请稍后重试' : '搜索失败,请检查网络'; + final msg = e.toString().contains('TimeoutException') + ? '搜索超时,请稍后重试' + : '搜索失败,请检查网络'; ToastUtil.show(context, msg); } } @@ -871,7 +875,7 @@ class _OnlineSearchPageState extends State { Widget _buildHistoryPanel(ColorScheme colors) { if (_history.isEmpty) - return _buildEmptyState(colors, '搜索你想看的影视作品', Icons.movie_outlined); + return _buildEmptyState(colors, '搜索你想看的影视/书籍作品', Icons.manage_search_outlined); return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), children: [ diff --git a/lib/pages/person_list_page.dart b/lib/pages/person_list_page.dart new file mode 100644 index 0000000..6452dd0 --- /dev/null +++ b/lib/pages/person_list_page.dart @@ -0,0 +1,372 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; +import '../models/data_models.dart'; +import 'movies/movie_detail_page.dart'; +import 'book/book_detail_page.dart'; + +/// 角色信息页面 - 列出所有导演/主演/编剧/作者 +class PersonListPage extends StatefulWidget { + const PersonListPage({super.key}); + + @override + State createState() => _PersonListPageState(); +} + +class _PersonListPageState extends State { + String _filter = 'all'; // all / 导演 / 编剧 / 主演 / 作者 + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List<_PersonEntry> _buildPersons() { + final provider = context.read(); + final map = {}; + + void addRole(String name, String role, {Movie? movie, Book? book}) { + if (name.trim().isEmpty) return; + final key = name.trim(); + map.putIfAbsent(key, () => _PersonEntry(name: key)); + map[key]!.roles.add(role); + if (movie != null && !map[key]!.movies.any((m) => m.id == movie.id)) { + map[key]!.movies.add(movie); + } + if (book != null && !map[key]!.books.any((b) => b.id == book.id)) { + map[key]!.books.add(book); + } + } + + for (final m in provider.movies.where((m) => !m.isDeleted)) { + for (final d in m.directors) addRole(d, '导演', movie: m); + for (final w in m.writers) addRole(w, '编剧', movie: m); + for (final a in m.actors) addRole(a, '主演', movie: m); + } + for (final b in provider.books.where((b) => !b.isDeleted)) { + for (final a in b.authors) addRole(a, '作者', book: b); + } + + var list = map.values.toList(); + list.sort((a, b) => a.name.compareTo(b.name)); + return list; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final allPersons = _buildPersons(); + + // 过滤 + var filtered = allPersons.where((p) { + if (_filter != 'all' && !p.roles.contains(_filter)) return false; + if (_searchQuery.isNotEmpty && !p.name.toLowerCase().contains(_searchQuery.toLowerCase())) return false; + return true; + }).toList(); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: const Text('角色信息')), + body: Column( + children: [ + // 搜索栏 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Container( + height: 44, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: [ + const SizedBox(width: 16), + Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _searchController, + style: TextStyle(fontSize: 15, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '搜索导演、编剧、演员、作者', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + filled: false, + ), + onChanged: (v) => setState(() => _searchQuery = v.trim()), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, + child: Container( + margin: const EdgeInsets.only(right: 10), + padding: const EdgeInsets.all(5), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.08), shape: BoxShape.circle), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + else + const SizedBox(width: 16), + ], + ), + ), + ), + + // 角色筛选 + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 4), + child: Row( + children: [ + for (final f in ['all', '导演', '编剧', '主演', '作者']) + Padding( + padding: const EdgeInsets.only(right: 6), + child: GestureDetector( + onTap: () => setState(() => _filter = f), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _filter == f ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Text(f == 'all' ? '全部' : f, + style: TextStyle(fontSize: 12, fontWeight: _filter == f ? FontWeight.w600 : FontWeight.normal, + color: _filter == f ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ), + ), + ], + ), + ), + + // 数量 + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('共 ${filtered.length} 人', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ), + ), + + // 列表 + Expanded( + child: filtered.isEmpty + ? Center(child: Text('暂无数据', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)))) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: filtered.length, + separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant), + itemBuilder: (_, i) => _buildPersonTile(filtered[i], colors), + ), + ), + ], + ), + ); + } + + Widget _buildPersonTile(_PersonEntry person, ColorScheme colors) { + final roleColors = { + '导演': const Color(0xFF4A90D9), + '编剧': const Color(0xFF009688), + '主演': const Color(0xFFE91E63), + '作者': const Color(0xFF7E57C2), + }; + final totalWorks = person.movies.length + person.books.length; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 4), + leading: CircleAvatar( + radius: 20, + backgroundColor: colors.surfaceContainerHighest, + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5)), + ), + ), + title: Text(person.name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final role in person.roles.toSet()) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text(role, style: TextStyle(fontSize: 10, color: roleColors[role] ?? colors.onSurface)), + ), + Text('$totalWorks 部作品', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ), + ), + trailing: Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => _PersonDetailPage(person: person), + )), + ); + } +} + +// ─── 人物详情页 ─── + +class _PersonDetailPage extends StatelessWidget { + final _PersonEntry person; + const _PersonDetailPage({required this.person}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final roleColors = { + '导演': const Color(0xFF4A90D9), + '编剧': const Color(0xFF009688), + '主演': const Color(0xFFE91E63), + '作者': const Color(0xFF7E57C2), + }; + + // 按类型分组作品 + final movieItems = <_WorkItem>[]; + final bookItems = <_WorkItem>[]; + + for (final m in person.movies) { + final roles = []; + if (m.directors.contains(person.name)) roles.add('导演'); + if (m.writers.contains(person.name)) roles.add('编剧'); + if (m.actors.contains(person.name)) roles.add('主演'); + movieItems.add(_WorkItem(title: m.title, roles: roles, path: m.posterPath, data: m)); + } + for (final b in person.books) { + bookItems.add(_WorkItem(title: b.title, roles: const ['作者'], path: b.coverPath, data: b)); + } + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: Text(person.name)), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 40), + children: [ + // 角色标签 + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final role in person.roles.toSet()) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text(role, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: roleColors[role] ?? colors.onSurface)), + ), + ], + ), + const SizedBox(height: 20), + + // 影视作品 + if (movieItems.isNotEmpty) ...[ + Text('影视作品(${movieItems.length})', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 8), + for (final item in movieItems) _buildWorkTile(context, item, colors, isMovie: true), + const SizedBox(height: 16), + ], + + // 书籍作品 + if (bookItems.isNotEmpty) ...[ + Text('书籍作品(${bookItems.length})', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 8), + for (final item in bookItems) _buildWorkTile(context, item, colors, isMovie: false), + ], + ], + ), + ); + } + + Widget _buildWorkTile(BuildContext context, _WorkItem item, ColorScheme colors, {required bool isMovie}) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + if (isMovie) { + Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + } else { + Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + } + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row( + children: [ + // 封面 + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 44, height: 44, + child: item.path != null && item.path!.isNotEmpty + ? Image(image: FileImage(File(item.path!)), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)))) + : Container(color: colors.surfaceContainerHighest, + child: Icon(isMovie ? Icons.movie_outlined : Icons.menu_book_outlined, + size: 16, color: colors.onSurface.withValues(alpha: 0.3))), + ), + ), + const SizedBox(width: 10), + // 标题 + 角色 + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 3), + Text(item.roles.join(' · '), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + ), + Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)), + ], + ), + ), + ), + ); + } +} + +// ─── 数据模型 ─── + +class _PersonEntry { + final String name; + final Set roles = {}; + final List movies = []; + final List books = []; + + _PersonEntry({required this.name}); +} + +class _WorkItem { + final String title; + final List roles; + final String? path; + final dynamic data; + + _WorkItem({required this.title, required this.roles, this.path, required this.data}); +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index cfb956b..df6233d 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -73,7 +73,8 @@ class _ProfilePageState extends State { Expanded( child: Consumer( builder: (context, provider, child) { - final movies = provider.movies.where((m) => !m.isDeleted).toList(); + final movies = + provider.movies.where((m) => !m.isDeleted).toList(); final books = provider.books.where((b) => !b.isDeleted).toList(); final notes = provider.notes.where((n) => !n.isDeleted).toList(); return SingleChildScrollView( @@ -111,8 +112,12 @@ class _ProfilePageState extends State { Widget _buildHero(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final coverPaths = [ - ...movies.where((m) => m.posterPath != null && m.posterPath!.isNotEmpty).map((m) => m.posterPath!), - ...books.where((b) => b.coverPath != null && b.coverPath!.isNotEmpty).map((b) => b.coverPath!), + ...movies + .where((m) => m.posterPath != null && m.posterPath!.isNotEmpty) + .map((m) => m.posterPath!), + ...books + .where((b) => b.coverPath != null && b.coverPath!.isNotEmpty) + .map((b) => b.coverPath!), ]..shuffle(); final hasData = coverPaths.length >= 4; @@ -126,17 +131,23 @@ class _ProfilePageState extends State { if (hasData) Positioned.fill(child: _buildPosterMosaic(coverPaths)) else - Positioned.fill(child: Container(decoration: BoxDecoration( + Positioned.fill( + child: Container( + decoration: BoxDecoration( gradient: LinearGradient( colors: [colors.primary, colors.primary.withValues(alpha: 0.6)], - begin: Alignment.topLeft, end: Alignment.bottomRight, + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), ))), - Positioned.fill(child: Container(color: hasData - ? Colors.black.withValues(alpha: 0.35) - : colors.surface.withValues(alpha: 0.82))), + Positioned.fill( + child: Container( + color: hasData + ? Colors.black.withValues(alpha: 0.35) + : colors.surface.withValues(alpha: 0.82))), Padding( - padding: EdgeInsets.fromLTRB(20, MediaQuery.of(context).padding.top + 20, 20, 20), + padding: EdgeInsets.fromLTRB( + 20, MediaQuery.of(context).padding.top + 20, 20, 20), child: Column( children: [ Row( @@ -144,24 +155,37 @@ class _ProfilePageState extends State { GestureDetector( onTap: _pickAvatar, child: Container( - width: 56, height: 56, + width: 56, + height: 56, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: hasData - ? Colors.white.withValues(alpha: 0.6) - : colors.outlineVariant, width: 1.5), + border: Border.all( + color: hasData + ? Colors.white.withValues(alpha: 0.6) + : colors.outlineVariant, + width: 1.5), ), clipBehavior: Clip.antiAlias, child: _avatarPath != null && _avatarPath!.isNotEmpty - ? FadeInLocalImage(path: _avatarPath, fit: BoxFit.cover, - errorWidget: Icon(Icons.person_outline, size: 28, color: hasData - ? Colors.white.withValues(alpha: 0.5) - : colors.onSurface.withValues(alpha: 0.3))) + ? FadeInLocalImage( + path: _avatarPath, + fit: BoxFit.cover, + errorWidget: Icon(Icons.person_outline, + size: 28, + color: hasData + ? Colors.white.withValues(alpha: 0.5) + : colors.onSurface + .withValues(alpha: 0.3))) : Container( - color: hasData ? Colors.white.withValues(alpha: 0.1) : colors.surfaceContainerHighest, - child: Icon(Icons.person_outline, size: 28, color: hasData - ? Colors.white.withValues(alpha: 0.5) - : colors.onSurface.withValues(alpha: 0.3))), + color: hasData + ? Colors.white.withValues(alpha: 0.1) + : colors.surfaceContainerHighest, + child: Icon(Icons.person_outline, + size: 28, + color: hasData + ? Colors.white.withValues(alpha: 0.5) + : colors.onSurface + .withValues(alpha: 0.3))), ), ), const SizedBox(width: 14), @@ -169,15 +193,23 @@ class _ProfilePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_nickname, style: TextStyle( - fontSize: 18, fontWeight: FontWeight.w600, - color: hasData ? Colors.white : colors.onSurface)), + Text(_nickname, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: hasData + ? Colors.white + : colors.onSurface)), const SizedBox(height: 4), - Text(_motto, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, + Text(_motto, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, color: hasData ? Colors.white.withValues(alpha: 0.85) - : colors.onSurface.withValues(alpha: 0.6))), + : colors.onSurface + .withValues(alpha: 0.6))), ], ), ), @@ -201,15 +233,19 @@ class _ProfilePageState extends State { Widget _buildPosterMosaic(List paths) { final posters = paths.take(12).toList(); - while (posters.length < 12) posters.add(posters[posters.length % paths.length]); + while (posters.length < 12) + posters.add(posters[posters.length % paths.length]); return GridView.builder( physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, mainAxisSpacing: 2, crossAxisSpacing: 2, + crossAxisCount: 4, + mainAxisSpacing: 2, + crossAxisSpacing: 2, ), itemCount: 12, - itemBuilder: (_, i) => FadeInLocalImage(path: posters[i], fit: BoxFit.cover), + itemBuilder: (_, i) => + FadeInLocalImage(path: posters[i], fit: BoxFit.cover), ); } @@ -217,11 +253,23 @@ class _ProfilePageState extends State { return Expanded( child: Column( children: [ - Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: hasData ? Colors.white : Theme.of(context).colorScheme.onSurface)), + Text(value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: hasData + ? Colors.white + : Theme.of(context).colorScheme.onSurface)), const SizedBox(height: 2), - Text(label, style: TextStyle(fontSize: 11, color: hasData - ? Colors.white.withValues(alpha: 0.85) - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))), + Text(label, + style: TextStyle( + fontSize: 11, + color: hasData + ? Colors.white.withValues(alpha: 0.85) + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6))), ], ), ); @@ -233,7 +281,11 @@ class _ProfilePageState extends State { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + child: Text(title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)), ); } @@ -244,7 +296,8 @@ class _ProfilePageState extends State { final watched = movies.where((m) => m.status == 'watched').length; final watching = movies.where((m) => m.status == 'watching').length; final wantTo = movies.where((m) => m.status == 'want_to_watch').length; - final recent = movies.toList()..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final recent = movies.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -271,7 +324,8 @@ class _ProfilePageState extends State { itemBuilder: (_, i) => _buildCoverCard( title: recent[i].title, imagePath: recent[i].posterPath, - onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: recent[i]), + onTap: () => Navigator.pushNamed(context, '/movie-detail', + arguments: recent[i]), ), ), ) @@ -288,7 +342,8 @@ class _ProfilePageState extends State { final read = books.where((b) => b.status == 'read').length; final reading = books.where((b) => b.status == 'reading').length; final wantTo = books.where((b) => b.status == 'want_to_read').length; - final recent = books.toList()..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final recent = books.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -315,7 +370,8 @@ class _ProfilePageState extends State { itemBuilder: (_, i) => _buildCoverCard( title: recent[i].title, imagePath: recent[i].coverPath, - onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: recent[i]), + onTap: () => Navigator.pushNamed(context, '/book-detail', + arguments: recent[i]), ), ), ) @@ -325,28 +381,36 @@ class _ProfilePageState extends State { ); } - Widget _buildStatusTag(String label, int count, bool active, ColorScheme colors) { + Widget _buildStatusTag( + String label, int count, bool active, ColorScheme colors) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 6, height: 6, + width: 6, + height: 6, decoration: BoxDecoration( shape: BoxShape.circle, - color: active ? colors.primary : colors.onSurface.withValues(alpha: 0.25), + color: active + ? colors.primary + : colors.onSurface.withValues(alpha: 0.25), ), ), const SizedBox(width: 4), - Text('$label $count', style: TextStyle( - fontSize: 12, - fontWeight: active ? FontWeight.w600 : FontWeight.w400, - color: active ? colors.onSurface : colors.onSurface.withValues(alpha: 0.5), - )), + Text('$label $count', + style: TextStyle( + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + color: active + ? colors.onSurface + : colors.onSurface.withValues(alpha: 0.5), + )), ], ); } - Widget _buildCoverCard({required String title, String? imagePath, VoidCallback? onTap}) { + Widget _buildCoverCard( + {required String title, String? imagePath, VoidCallback? onTap}) { final colors = Theme.of(context).colorScheme; return GestureDetector( onTap: onTap, @@ -364,14 +428,24 @@ class _ProfilePageState extends State { ), clipBehavior: Clip.antiAlias, child: imagePath != null && imagePath.isNotEmpty - ? FadeInLocalImage(path: imagePath, fit: BoxFit.cover, - errorWidget: Icon(Icons.image_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2))) - : Icon(Icons.image_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)), + ? FadeInLocalImage( + path: imagePath, + fit: BoxFit.cover, + errorWidget: Icon(Icons.image_outlined, + size: 20, + color: colors.onSurface.withValues(alpha: 0.2))) + : Icon(Icons.image_outlined, + size: 20, + color: colors.onSurface.withValues(alpha: 0.2)), ), ), const SizedBox(height: 4), - Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.6))), + Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.6))), ], ), ), @@ -382,19 +456,37 @@ class _ProfilePageState extends State { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), - child: Center(child: Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)))), + child: Center( + child: Text(text, + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.3)))), ); } // ─── 标签模块 ────────────────────────────────────────────────────── - Widget _buildTagsSection(List movies, List books, List notes) { + Widget _buildTagsSection( + List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final freq = {}; - for (final m in movies) { for (final g in m.genres) { freq[g] = (freq[g] ?? 0) + 1; } } - for (final b in books) { for (final g in b.genres) { freq[g] = (freq[g] ?? 0) + 1; } } - for (final n in notes) { for (final t in n.tags) { freq[t] = (freq[t] ?? 0) + 1; } } - final sorted = freq.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + for (final m in movies) { + for (final g in m.genres) { + freq[g] = (freq[g] ?? 0) + 1; + } + } + for (final b in books) { + for (final g in b.genres) { + freq[g] = (freq[g] ?? 0) + 1; + } + } + for (final n in notes) { + for (final t in n.tags) { + freq[t] = (freq[t] ?? 0) + 1; + } + } + final sorted = freq.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); final topTags = sorted.take(10).map((e) => e.key).toList(); if (topTags.isEmpty) return const SizedBox.shrink(); @@ -406,15 +498,27 @@ class _ProfilePageState extends State { padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ - Text('常用标签', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text('常用标签', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)), const Spacer(), GestureDetector( - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const TagManagementPage())), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const TagManagementPage())), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text('管理', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), - Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + Text('管理', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + Icon(Icons.chevron_right, + size: 16, + color: colors.onSurface.withValues(alpha: 0.3)), ], ), ), @@ -425,15 +529,22 @@ class _ProfilePageState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Wrap( - spacing: 8, runSpacing: 8, - children: topTags.map((tag) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Text(tag, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7))), - )).toList(), + spacing: 8, + runSpacing: 8, + children: topTags + .map((tag) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text(tag, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.7))), + )) + .toList(), ), ), ], @@ -445,11 +556,31 @@ class _ProfilePageState extends State { Widget _buildToolsGrid(BuildContext context) { final colors = Theme.of(context).colorScheme; final tools = [ - (Icons.explore_outlined, '漫步', () => Navigator.push(context, MaterialPageRoute(builder: (_) => const StrollPage()))), - (Icons.analytics_outlined, '统计', () => Navigator.push(context, MaterialPageRoute(builder: (_) => const StatisticsPage()))), + ( + Icons.explore_outlined, + '漫步', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const StrollPage())) + ), + ( + Icons.analytics_outlined, + '统计', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const StatisticsPage())) + ), (Icons.backup_outlined, '备份', () => _showBackupOptions(context)), - (Icons.settings_outlined, '设置', () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()))), - (Icons.delete_outline, '回收', () => Navigator.push(context, MaterialPageRoute(builder: (_) => const RecycleBinPage()))), + ( + Icons.settings_outlined, + '设置', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const SettingsPage())) + ), + ( + Icons.delete_outline, + '回收', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const RecycleBinPage())) + ), ]; return Padding( @@ -467,24 +598,43 @@ class _ProfilePageState extends State { InkWell( onTap: e.value.$3, borderRadius: BorderRadius.only( - topLeft: e.key == 0 ? const Radius.circular(12) : Radius.zero, - topRight: e.key == 0 ? const Radius.circular(12) : Radius.zero, - bottomLeft: isLast ? const Radius.circular(12) : Radius.zero, - bottomRight: isLast ? const Radius.circular(12) : Radius.zero, + topLeft: + e.key == 0 ? const Radius.circular(12) : Radius.zero, + topRight: + e.key == 0 ? const Radius.circular(12) : Radius.zero, + bottomLeft: + isLast ? const Radius.circular(12) : Radius.zero, + bottomRight: + isLast ? const Radius.circular(12) : Radius.zero, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), child: Row( children: [ - Icon(e.value.$1, size: 18, color: colors.onSurface.withValues(alpha: 0.6)), + Icon(e.value.$1, + size: 18, + color: colors.onSurface.withValues(alpha: 0.6)), const SizedBox(width: 12), - Expanded(child: Text(e.value.$2, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7)))), - Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)), + Expanded( + child: Text(e.value.$2, + style: TextStyle( + fontSize: 13, + color: colors.onSurface + .withValues(alpha: 0.7)))), + Icon(Icons.chevron_right, + size: 16, + color: colors.onSurface.withValues(alpha: 0.2)), ], ), ), ), - if (!isLast) Divider(height: 1, indent: 46, endIndent: 16, color: colors.outlineVariant), + if (!isLast) + Divider( + height: 1, + indent: 46, + endIndent: 16, + color: colors.outlineVariant), ], ); }).toList(), @@ -508,8 +658,90 @@ class _ProfilePageState extends State { // ─── 头像 ──────────────────────────────────────────────────────────── Future _pickAvatar() async { + final colors = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: Text('更换头像', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)))), + const SizedBox(height: 8), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.folder_outlined, + size: 20, color: colors.onSurface.withValues(alpha: 0.6))), + title: Text('从文件管理器选择', + style: TextStyle(fontSize: 14, color: colors.onSurface)), + trailing: Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () async { + Navigator.pop(ctx); + await _pickAvatarFromFile(); + }, + ), + if (_avatarPath != null && _avatarPath!.isNotEmpty) ...[ + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.delete_outline, + size: 20, color: colors.error)), + title: Text('移除头像', + style: TextStyle(fontSize: 14, color: colors.error)), + trailing: Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () async { + Navigator.pop(ctx); + await _userPrefs.clearAvatarPath(); + setState(() => _avatarPath = null); + }, + ), + ], + const SizedBox(height: 8), + ]), + ), + ); + } + + Future _pickAvatarFromFile() async { try { - final pickedFile = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 400, maxHeight: 400, imageQuality: 85); + final pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 400, + maxHeight: 400, + imageQuality: 85); if (pickedFile != null) { final appDir = await getApplicationDocumentsDirectory(); final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; @@ -532,32 +764,80 @@ class _ProfilePageState extends State { showModalBottomSheet( context: context, backgroundColor: colors.surface, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + 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: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), const SizedBox(height: 20), - Align(alignment: Alignment.centerLeft, child: Text('选择备份方式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + Align( + alignment: Alignment.centerLeft, + child: Text('选择备份方式', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface))), const SizedBox(height: 16), ListTile( contentPadding: EdgeInsets.zero, - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.folder_outlined, color: colors.onSurface.withValues(alpha: 0.6))), - title: Text('本地备份', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - subtitle: Text('备份到本地文件夹,支持恢复', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), - onTap: () { Navigator.pop(ctx); _push(context, const BackupPage()); }, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.folder_outlined, + color: colors.onSurface.withValues(alpha: 0.6))), + title: Text('本地备份', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + subtitle: Text('备份到本地文件夹,支持恢复', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + _push(context, const BackupPage()); + }, ), Divider(height: 0.5, color: colors.outlineVariant), ListTile( contentPadding: EdgeInsets.zero, - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.cloud_outlined, color: colors.onSurface.withValues(alpha: 0.6))), - title: Text('云备份', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - subtitle: Text('通过 WebDAV 同步到云端', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), - onTap: () { Navigator.pop(ctx); _push(context, const CloudSyncPage()); }, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.cloud_outlined, + color: colors.onSurface.withValues(alpha: 0.6))), + title: Text('云备份', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + subtitle: Text('通过 WebDAV 同步到云端', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + _push(context, const CloudSyncPage()); + }, ), const SizedBox(height: 20), ], @@ -580,12 +860,14 @@ class _SettingsPageState extends State { final UserPrefs _userPrefs = UserPrefs(); bool _hideBottomNavOnScroll = true; int _themeMode = 0; // 0=系统, 1=浅色, 2=深色 + String _fontFamily = ''; @override void initState() { super.initState(); _hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll; _themeMode = _userPrefs.themeMode; + _fontFamily = _userPrefs.fontFamily; } @override @@ -601,27 +883,58 @@ class _SettingsPageState extends State { icon: Icons.apps_outlined, title: '应用图标', subtitle: '更换桌面应用图标', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AppIconPickerPage())), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const AppIconPickerPage())), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildNavigationItem( icon: Icons.view_list_outlined, title: '主界面设置', subtitle: '启动标签、模块显示开关', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MainContentSettingsPage())), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MainContentSettingsPage())), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildNavigationItem( icon: Icons.dashboard_outlined, title: '布局设置', subtitle: '影视、阅读、笔记的展示样式', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const LayoutSettingsPage())), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const LayoutSettingsPage())), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildThemeModeSelector(), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildColorSchemeSelector(), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildFontSelector(), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildSectionHeader('其他设置'), _buildActionItem( icon: Icons.person_outline, @@ -629,14 +942,25 @@ class _SettingsPageState extends State { subtitle: '修改昵称和座右铭', onTap: () => _showProfileEditDialog(context), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildActionItem( icon: Icons.manage_search, title: '增强搜索', subtitle: '在线搜索影视和书籍信息', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const EnhancedSearchSettingsPage())), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const EnhancedSearchSettingsPage())), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildSwitchItem( icon: Icons.swipe_vertical_outlined, title: '底部导航栏滚动隐藏', @@ -644,7 +968,11 @@ class _SettingsPageState extends State { value: _hideBottomNavOnScroll, onChanged: _toggleHideBottomNavOnScroll, ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildSectionHeader('数据管理'), _buildActionItem( icon: Icons.cleaning_services_outlined, @@ -652,45 +980,78 @@ class _SettingsPageState extends State { subtitle: '清理未在数据库中引用的图片文件', onTap: () => _showClearCacheDialog(context), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildSectionHeader('帮助'), _buildActionItem( icon: Icons.language_outlined, title: '查看官网', subtitle: '在浏览器中打开官方网站', - onTap: () => launchUrl(Uri.parse('https://mooknote.iletter.top/#/')), + onTap: () => + launchUrl(Uri.parse('https://mooknote.iletter.top/#/')), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildActionItem( icon: Icons.code_outlined, title: '开发日志', subtitle: '在浏览器中查看项目开发记录', - onTap: () => launchUrl(Uri.parse('http://docmost.iletter.top/s/technologyNote/p/mook-note-lHmPTswdDC')), + onTap: () => launchUrl(Uri.parse( + 'http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC')), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildActionItem( icon: Icons.update_outlined, title: '更新日志', subtitle: '查看版本更新内容', - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangelogPage())), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const ChangelogPage())), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildActionItem( icon: Icons.description_outlined, title: '用户服务协议', subtitle: '查看用户服务协议', - onTap: () => Navigator.push(context, MaterialPageRoute( - builder: (_) => const LegalPage(slug: 'terms', title: '用户服务协议'))), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LegalPage(slug: 'terms', title: '用户服务协议'))), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildActionItem( icon: Icons.shield_outlined, title: '隐私政策', subtitle: '查看隐私政策', - onTap: () => Navigator.push(context, MaterialPageRoute( - builder: (_) => const LegalPage(slug: 'privacy', title: '隐私政策'))), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LegalPage(slug: 'privacy', title: '隐私政策'))), ), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), ], ), ); @@ -711,7 +1072,11 @@ class _SettingsPageState extends State { backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('个人信息', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + title: Text('个人信息', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface)), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -720,11 +1085,16 @@ class _SettingsPageState extends State { style: TextStyle(fontSize: 14, color: colors.onSurface), decoration: InputDecoration( labelText: '昵称', - labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5)), + labelStyle: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.5)), filled: true, fillColor: colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), ), ), const SizedBox(height: 12), @@ -734,11 +1104,16 @@ class _SettingsPageState extends State { style: TextStyle(fontSize: 14, color: colors.onSurface), decoration: InputDecoration( labelText: '座右铭', - labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5)), + labelStyle: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.5)), filled: true, fillColor: colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), ), ), ], @@ -750,7 +1125,8 @@ class _SettingsPageState extends State { style: TextButton.styleFrom( foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), ), child: const Text('取消', style: TextStyle(fontSize: 14)), ), @@ -768,7 +1144,8 @@ class _SettingsPageState extends State { foregroundColor: colors.onPrimary, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), ), child: const Text('保存', style: TextStyle(fontSize: 14)), ), @@ -778,7 +1155,11 @@ class _SettingsPageState extends State { } static const _themeModeLabels = ['跟随系统', '浅色模式', '深色模式']; - static const _themeModeIcons = [Icons.brightness_auto, Icons.light_mode, Icons.dark_mode]; + static const _themeModeIcons = [ + Icons.brightness_auto, + Icons.light_mode, + Icons.dark_mode + ]; Widget _buildThemeModeSelector() { final colors = Theme.of(context).colorScheme; @@ -788,16 +1169,33 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(_themeModeIcons[_themeMode], color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(_themeModeIcons[_themeMode], + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), const SizedBox(width: 12), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('主题模式', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - const SizedBox(height: 2), - Text(_themeModeLabels[_themeMode], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('主题模式', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(_themeModeLabels[_themeMode], + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ]), ), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25), size: 20), ], ), ), @@ -810,29 +1208,53 @@ class _SettingsPageState extends State { context: context, backgroundColor: Colors.transparent, builder: (ctx) => Container( - decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16))), padding: const EdgeInsets.only(bottom: 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 12), - decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 12), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: Align(alignment: Alignment.centerLeft, child: Text('主题模式', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface))), + child: Align( + alignment: Alignment.centerLeft, + child: Text('主题模式', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.onSurface))), ), const SizedBox(height: 8), for (int i = 0; i < _themeModeLabels.length; i++) InkWell( - onTap: () async { await _setThemeMode(i); Navigator.pop(ctx); }, + onTap: () async { + await _setThemeMode(i); + Navigator.pop(ctx); + }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 10), child: Row( children: [ - Icon(_themeModeIcons[i], size: 18, color: colors.onSurface.withValues(alpha: 0.6)), + Icon(_themeModeIcons[i], + size: 18, + color: colors.onSurface.withValues(alpha: 0.6)), const SizedBox(width: 12), - Expanded(child: Text(_themeModeLabels[i], style: TextStyle(fontSize: 13, color: colors.onSurface))), - if (_themeMode == i) Icon(Icons.check, color: colors.onSurface, size: 18), + Expanded( + child: Text(_themeModeLabels[i], + style: TextStyle( + fontSize: 13, color: colors.onSurface))), + if (_themeMode == i) + Icon(Icons.check, color: colors.onSurface, size: 18), ], ), ), @@ -860,23 +1282,40 @@ class _SettingsPageState extends State { final colors = Theme.of(context).colorScheme; final provider = context.watch(); final currentIndex = provider.colorSchemeIndex; + final label = currentIndex == -1 ? '莫奈取色' : AppTheme.colorSchemeNames[currentIndex]; return InkWell( onTap: () => _showColorSchemePicker(), child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), - child: Icon(Icons.palette_outlined, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(currentIndex == -1 ? Icons.auto_awesome : Icons.palette_outlined, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), const SizedBox(width: 12), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('配色方案', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - const SizedBox(height: 2), - Text(AppTheme.colorSchemeNames[currentIndex], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('配色方案', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ]), ), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25), size: 20), ], ), ), @@ -890,16 +1329,36 @@ class _SettingsPageState extends State { context: context, backgroundColor: Colors.transparent, builder: (ctx) => Container( - decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16))), padding: const EdgeInsets.only(bottom: 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), - decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: Align(alignment: Alignment.centerLeft, child: Text('配色方案', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface))), + child: Align( + alignment: Alignment.centerLeft, + child: Text('配色方案', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.onSurface))), + ), + const SizedBox(height: 12), + // 莫奈自动取色 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildMonetOption(provider, colors, ctx), ), const SizedBox(height: 12), Padding( @@ -907,24 +1366,48 @@ class _SettingsPageState extends State { child: GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: 2.2), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 2.2), itemCount: AppTheme.seedColors.length, itemBuilder: (_, i) { final selected = provider.colorSchemeIndex == i; return GestureDetector( - onTap: () { provider.setColorScheme(i); Navigator.pop(ctx); }, + onTap: () { + provider.setColorScheme(i); + Navigator.pop(ctx); + }, child: Container( decoration: BoxDecoration( - color: selected ? AppTheme.seedColors[i].withValues(alpha: 0.12) : colors.surfaceContainerHighest, + color: selected + ? AppTheme.seedColors[i].withValues(alpha: 0.12) + : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10), - border: Border.all(color: selected ? AppTheme.seedColors[i] : Colors.transparent, width: 1.5), + border: Border.all( + color: selected + ? AppTheme.seedColors[i] + : Colors.transparent, + width: 1.5), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container(width: 14, height: 14, decoration: BoxDecoration(color: AppTheme.seedColors[i], shape: BoxShape.circle)), + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: AppTheme.seedColors[i], + shape: BoxShape.circle)), const SizedBox(width: 8), - Text(AppTheme.colorSchemeNames[i], style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)), + Text(AppTheme.colorSchemeNames[i], + style: TextStyle( + fontSize: 13, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + color: colors.onSurface)), ], ), ), @@ -938,7 +1421,197 @@ class _SettingsPageState extends State { ); } - Widget _buildSwitchItem({required IconData icon, required String title, required String subtitle, required bool value, required ValueChanged onChanged}) { + Widget _buildMonetOption(AppProvider provider, ColorScheme colors, BuildContext ctx) { + final selected = provider.colorSchemeIndex == -1; + final monetColor = AppTheme.monetColor; + final available = monetColor != null; + return GestureDetector( + onTap: available + ? () { provider.setColorScheme(-1); Navigator.pop(ctx); } + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: selected + ? (monetColor ?? colors.primary).withValues(alpha: 0.12) + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected + ? (monetColor ?? colors.primary) + : available ? colors.outlineVariant : colors.outlineVariant.withValues(alpha: 0.5), + width: selected ? 1.5 : 0.5), + ), + child: Row( + children: [ + Container( + width: 20, height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: monetColor != null + ? LinearGradient(colors: [monetColor, monetColor.withValues(alpha: 0.6)]) + : null, + color: available ? null : colors.outlineVariant, + ), + child: available ? null : Icon(Icons.auto_awesome, size: 12, color: colors.onSurface.withValues(alpha: 0.3)), + ), + const SizedBox(width: 10), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('莫奈取色', style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: available ? colors.onSurface : colors.onSurface.withValues(alpha: 0.35))), + Text(available ? '从系统壁纸自动提取配色' : '此设备不支持', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))), + ]), + ), + if (selected) Icon(Icons.check_circle, size: 18, color: monetColor ?? colors.primary), + ], + ), + ), + ); + } + + // ─── 字体选择器 ─── + + static const _fontLabels = ['系统默认', '霞鹜文楷', 'OPPO Sans', '思源宋体', '得意黑']; + static const _fontValues = [ + '', + 'LXGWWenKai', + 'OPPOSans', + 'NotoSerifSC', + 'SmileySans' + ]; + static const _fontIcons = [ + Icons.font_download_outlined, + Icons.brush_outlined, + Icons.phone_android, + Icons.text_fields, + Icons.emoji_emotions_outlined + ]; + + Widget _buildFontSelector() { + final colors = Theme.of(context).colorScheme; + final idx = _fontValues.indexOf(_fontFamily); + final label = idx >= 0 ? _fontLabels[idx] : '系统默认'; + return InkWell( + onTap: _showFontPicker, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row(children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.font_download_outlined, + size: 18, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('字体', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ])), + Icon(Icons.chevron_right, + size: 20, color: colors.onSurface.withValues(alpha: 0.25)), + ]), + ), + ); + } + + void _showFontPicker() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16))), + padding: const EdgeInsets.only(bottom: 20), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: Text('字体', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.onSurface)))), + const SizedBox(height: 8), + for (int i = 0; i < _fontLabels.length; i++) ...[ + if (i > 0) + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(_fontIcons[i], + size: 20, + color: _fontFamily == _fontValues[i] + ? colors.primary + : colors.onSurface.withValues(alpha: 0.6))), + title: Text(_fontLabels[i], + style: TextStyle( + fontSize: 14, + fontWeight: _fontFamily == _fontValues[i] + ? FontWeight.w600 + : FontWeight.w400, + color: colors.onSurface)), + trailing: _fontFamily == _fontValues[i] + ? Icon(Icons.check, size: 20, color: colors.primary) + : null, + onTap: () { + Navigator.pop(ctx); + _setFontFamily(_fontValues[i]); + }, + ), + ], + ]), + ), + ); + } + + void _setFontFamily(String family) { + setState(() => _fontFamily = family); + context.read().setFontFamily(family); + } + + Widget _buildSwitchItem( + {required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged}) { final colors = Theme.of(context).colorScheme; return InkWell( onTap: () => onChanged(!value), @@ -946,16 +1619,38 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icon, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), const SizedBox(width: 12), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - const SizedBox(height: 2), - Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ]), ), - Switch(value: value, onChanged: onChanged, activeColor: colors.primary, activeTrackColor: colors.primary.withOpacity(0.3), inactiveThumbColor: colors.surface, inactiveTrackColor: colors.outline), + Switch( + value: value, + onChanged: onChanged, + activeColor: colors.primary, + activeTrackColor: colors.primary.withOpacity(0.3), + inactiveThumbColor: colors.surface, + inactiveTrackColor: colors.outline), ], ), ), @@ -966,11 +1661,19 @@ class _SettingsPageState extends State { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), - child: Text(title, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface)), + child: Text(title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: colors.onSurface)), ); } - Widget _buildNavigationItem({required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) { + Widget _buildNavigationItem( + {required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap}) { final colors = Theme.of(context).colorScheme; return InkWell( onTap: onTap, @@ -978,23 +1681,44 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icon, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), const SizedBox(width: 12), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - const SizedBox(height: 2), - Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ]), ), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25), size: 20), ], ), ), ); } - Widget _buildActionItem({required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) { + Widget _buildActionItem( + {required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap}) { final colors = Theme.of(context).colorScheme; return InkWell( onTap: onTap, @@ -1002,14 +1726,30 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Row( children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icon, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), const SizedBox(width: 12), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - const SizedBox(height: 2), - Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ]), ), ], ), @@ -1022,37 +1762,72 @@ class _SettingsPageState extends State { showDialog( context: pageContext, builder: (dialogContext) => AlertDialog( - backgroundColor: colors.surface, elevation: 0, + backgroundColor: colors.surface, + elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('清除缓存数据', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + title: Text('清除缓存数据', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface)), content: Text('这将删除所有未在数据库中引用的图片文件。确定要继续吗?', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.6), + height: 1.5)), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text('取消', + style: TextStyle( + color: colors.onSurface.withValues(alpha: 0.6)))), ElevatedButton( onPressed: () async { Navigator.pop(dialogContext); await _clearCacheData(pageContext); }, - style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), child: const Text('清除'), ), ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + actionsPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ); } Future _clearCacheData(BuildContext context) async { try { - showDialog(context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator())); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator())); final appProvider = context.read(); + + // 1. 清理未引用的图片文件 final dbImagePaths = await _getAllDbImagePaths(appProvider); - final deletedCount = await _cleanImageDirectory(dbImagePaths); + final deletedImages = await _cleanImageDirectory(dbImagePaths); + + // 2. 清理临时目录缓存(分享海报、备份ZIP等) + final deletedTemp = await _cleanTempDirectory(); + Navigator.pop(context); - if (context.mounted) ToastUtil.show(context, '已清理 $deletedCount 个缓存文件'); + if (context.mounted) { + final total = deletedImages + deletedTemp; + if (total == 0) { + ToastUtil.show(context, '没有需要清理的缓存'); + } else { + ToastUtil.show( + context, '已清理 $deletedImages 个孤立图片,$deletedTemp 个临时文件'); + } + } } catch (e) { Navigator.pop(context); if (context.mounted) ToastUtil.show(context, '清理失败: $e'); @@ -1061,11 +1836,21 @@ class _SettingsPageState extends State { Future> _getAllDbImagePaths(AppProvider provider) async { final paths = {}; - for (final movie in provider.movies) { if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!); } - for (final book in provider.books) { if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!); } - for (final note in provider.notes) { for (final p in note.images) { if (p.isNotEmpty) paths.add(p); } } + for (final movie in provider.movies) { + if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!); + } + for (final book in provider.books) { + if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!); + } + for (final note in provider.notes) { + for (final p in note.images) { + if (p.isNotEmpty) paths.add(p); + } + } for (final movieId in provider.movies.map((m) => m.id)) { - for (final poster in await provider.getMoviePosters(movieId)) { if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath); } + for (final poster in await provider.getMoviePosters(movieId)) { + if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath); + } } return paths; } @@ -1076,23 +1861,84 @@ class _SettingsPageState extends State { final appDir = await getApplicationDocumentsDirectory(); final imagesDir = Directory('${appDir.path}/images'); if (!await imagesDir.exists()) return 0; - await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { - if (entity is File && !dbImagePaths.contains(entity.path) && !path.basename(entity.path).startsWith('avatar')) { - try { await entity.delete(); deletedCount++; } catch (_) {} + await for (final entity + in imagesDir.list(recursive: true, followLinks: false)) { + if (entity is File && + !dbImagePaths.contains(entity.path) && + !path.basename(entity.path).startsWith('avatar')) { + try { + await entity.delete(); + deletedCount++; + } catch (_) {} } } - } catch (e) { debugPrint('清理图片目录失败: $e'); } + } catch (e) { + debugPrint('清理图片目录失败: $e'); + } + return deletedCount; + } + + Future _cleanTempDirectory() async { + int deletedCount = 0; + final now = DateTime.now(); + + // 1. 清理临时目录中的分享海报(保留备份ZIP) + try { + final tempDir = await getTemporaryDirectory(); + if (await tempDir.exists()) { + await for (final entity in tempDir.list(followLinks: false)) { + if (entity is File) { + final name = path.basename(entity.path); + if (name.startsWith('book_poster_') || + name.startsWith('movie_poster_') || + name.startsWith('note_share_') || + name.startsWith('mooknote_download') || + name.startsWith('mooknote_bidir')) { + try { + final stat = await entity.stat(); + if (now.difference(stat.modified).inHours >= 1) { + await entity.delete(); + deletedCount++; + } + } catch (_) {} + } + } + } + } + } catch (e) { + debugPrint('清理临时目录失败: $e'); + } + + // 2. 清理应用缓存目录 (/data/user/0/{package}/cache/) + try { + final cacheDir = await getApplicationCacheDirectory(); + if (await cacheDir.exists()) { + await for (final entity + in cacheDir.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { + await entity.delete(); + deletedCount++; + } catch (_) {} + } + } + } + } catch (e) { + debugPrint('清理缓存目录失败: $e'); + } + return deletedCount; } } -// ─── 主界面设置 ──────────────────────────────────────────────────────── +// ─── 主界面设置 ─── class MainContentSettingsPage extends StatefulWidget { const MainContentSettingsPage({super.key}); @override - State createState() => _MainContentSettingsPageState(); + State createState() => + _MainContentSettingsPageState(); } class _MainContentSettingsPageState extends State { @@ -1128,19 +1974,28 @@ class _MainContentSettingsPageState extends State { } Future _toggleMovieTab(bool value) async { - if (!value && _enabledTabCount <= 1) { ToastUtil.show(context, '至少保留一个标签页'); return; } + if (!value && _enabledTabCount <= 1) { + ToastUtil.show(context, '至少保留一个标签页'); + return; + } await _userPrefs.setShowMovieTab(value); setState(() => _showMovieTab = value); } Future _toggleBookTab(bool value) async { - if (!value && _enabledTabCount <= 1) { ToastUtil.show(context, '至少保留一个标签页'); return; } + if (!value && _enabledTabCount <= 1) { + ToastUtil.show(context, '至少保留一个标签页'); + return; + } await _userPrefs.setShowBookTab(value); setState(() => _showBookTab = value); } Future _toggleNoteTab(bool value) async { - if (!value && _enabledTabCount <= 1) { ToastUtil.show(context, '至少保留一个标签页'); return; } + if (!value && _enabledTabCount <= 1) { + ToastUtil.show(context, '至少保留一个标签页'); + return; + } await _userPrefs.setShowNoteTab(value); setState(() => _showNoteTab = value); } @@ -1160,19 +2015,46 @@ class _MainContentSettingsPageState extends State { children: [ _buildSectionHeader('启动设置'), _buildDefaultTabSelector(), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), _buildSectionHeader('模块开关'), - _buildSwitchItem(Icons.movie_outlined, '观影', '记录和管理观影记录', _showMovieTab, _toggleMovieTab), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildSwitchItem(Icons.menu_book_outlined, '阅读', '记录和管理阅读记录', _showBookTab, _toggleBookTab), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildSwitchItem(Icons.note_outlined, '笔记', '记录和管理笔记', _showNoteTab, _toggleNoteTab), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档', _showNotePlusTab, _toggleNotePlusTab), - Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildSwitchItem(Icons.movie_outlined, '观影', '记录和管理观影记录', + _showMovieTab, _toggleMovieTab), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem(Icons.menu_book_outlined, '阅读', '记录和管理阅读记录', + _showBookTab, _toggleBookTab), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem(Icons.note_outlined, '笔记', '记录和管理笔记', _showNoteTab, + _toggleNoteTab), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档', + _showNotePlusTab, _toggleNotePlusTab), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), - child: Text('至少保留一个模块,关闭后对应标签页将不再显示。', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + child: Text('至少保留一个模块,关闭后对应标签页将不再显示。', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.3))), ), ], ), @@ -1183,37 +2065,82 @@ class _MainContentSettingsPageState extends State { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), - child: Text(title, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface)), + child: Text(title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: colors.onSurface)), ); } - Widget _buildSwitchItem(IconData icon, String title, String subtitle, bool value, ValueChanged onChanged) { + Widget _buildSwitchItem(IconData icon, String title, String subtitle, + bool value, ValueChanged onChanged) { final colors = Theme.of(context).colorScheme; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), - title: Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - subtitle: Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - trailing: Switch(value: value, onChanged: onChanged, activeColor: colors.primary, activeTrackColor: colors.primary.withOpacity(0.3), inactiveThumbColor: colors.surface, inactiveTrackColor: colors.outline), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icon, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + title: Text(title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + subtitle: Text(subtitle, + style: TextStyle( + fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Switch( + value: value, + onChanged: onChanged, + activeColor: colors.primary, + activeTrackColor: colors.primary.withOpacity(0.3), + inactiveThumbColor: colors.surface, + inactiveTrackColor: colors.outline), ); } Widget _buildDefaultTabSelector() { final colors = Theme.of(context).colorScheme; final labels = ['影视', '阅读', '笔记']; - final icons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined]; + final icons = [ + Icons.movie_outlined, + Icons.menu_book_outlined, + Icons.note_outlined + ]; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.home_outlined, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), - title: Text('默认启动标签', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - subtitle: Text('打开应用时默认显示的页面', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.home_outlined, + color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + title: Text('默认启动标签', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + subtitle: Text('打开应用时默认显示的页面', + style: TextStyle( + fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(labels[_defaultTabIndex], style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + Text(labels[_defaultTabIndex], + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(width: 4), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25), size: 20), ], ), onTap: () => _showDefaultTabPicker(labels, icons), @@ -1226,21 +2153,54 @@ class _MainContentSettingsPageState extends State { context: context, backgroundColor: Colors.transparent, builder: (ctx) => Container( - decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16))), padding: const EdgeInsets.symmetric(vertical: 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), const SizedBox(height: 20), - Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Text('默认启动标签', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text('默认启动标签', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)))), const SizedBox(height: 16), for (int i = 0; i < labels.length; i++) ListTile( - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icons[i], color: colors.onSurface.withValues(alpha: 0.6))), - title: Text(labels[i], style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - trailing: _defaultTabIndex == i ? Icon(Icons.check, color: colors.onSurface, size: 20) : null, - onTap: () async { await _userPrefs.setDefaultMainTabIndex(i); setState(() => _defaultTabIndex = i); Navigator.pop(ctx); }, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], + color: colors.onSurface.withValues(alpha: 0.6))), + title: Text(labels[i], + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + trailing: _defaultTabIndex == i + ? Icon(Icons.check, color: colors.onSurface, size: 20) + : null, + onTap: () async { + await _userPrefs.setDefaultMainTabIndex(i); + setState(() => _defaultTabIndex = i); + Navigator.pop(ctx); + }, ), const SizedBox(height: 8), ], @@ -1282,20 +2242,56 @@ class _LayoutSettingsPageState extends State { body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ - _buildSection('影视布局', [ - ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('海报网格', style: TextStyle(fontSize: 12))), - ButtonSegment(value: 1, icon: Icon(Icons.view_list_outlined, size: 16), label: Text('列表', style: TextStyle(fontSize: 12))), - ButtonSegment(value: 2, icon: Icon(Icons.crop_landscape_outlined, size: 16), label: Text('大图卡片', style: TextStyle(fontSize: 12))), - ], _movieLayout, (v) => _setLayout('movie', v)), - _buildSection('阅读布局', [ - ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('封面网格', style: TextStyle(fontSize: 12))), - ButtonSegment(value: 1, icon: Icon(Icons.view_list_outlined, size: 16), label: Text('列表', style: TextStyle(fontSize: 12))), - ], _bookLayout, (v) => _setLayout('book', v)), - _buildSection('笔记布局', [ - ButtonSegment(value: 0, icon: Icon(Icons.view_list_outlined, size: 16), label: Text('列表', style: TextStyle(fontSize: 12))), - ButtonSegment(value: 1, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('瀑布流', style: TextStyle(fontSize: 12))), - ButtonSegment(value: 2, icon: Icon(Icons.timeline_outlined, size: 16), label: Text('时间线', style: TextStyle(fontSize: 12))), - ], _noteLayout, (v) => _setLayout('note', v)), + _buildSection( + '影视布局', + [ + ButtonSegment( + value: 0, + icon: Icon(Icons.grid_view_outlined, size: 16), + label: Text('海报网格', style: TextStyle(fontSize: 12))), + ButtonSegment( + value: 1, + icon: Icon(Icons.view_list_outlined, size: 16), + label: Text('列表', style: TextStyle(fontSize: 12))), + ButtonSegment( + value: 2, + icon: Icon(Icons.crop_landscape_outlined, size: 16), + label: Text('大图卡片', style: TextStyle(fontSize: 12))), + ], + _movieLayout, + (v) => _setLayout('movie', v)), + _buildSection( + '阅读布局', + [ + ButtonSegment( + value: 0, + icon: Icon(Icons.grid_view_outlined, size: 16), + label: Text('封面网格', style: TextStyle(fontSize: 12))), + ButtonSegment( + value: 1, + icon: Icon(Icons.view_list_outlined, size: 16), + label: Text('列表', style: TextStyle(fontSize: 12))), + ], + _bookLayout, + (v) => _setLayout('book', v)), + _buildSection( + '笔记布局', + [ + ButtonSegment( + value: 0, + icon: Icon(Icons.view_list_outlined, size: 16), + label: Text('列表', style: TextStyle(fontSize: 12))), + ButtonSegment( + value: 1, + icon: Icon(Icons.grid_view_outlined, size: 16), + label: Text('瀑布流', style: TextStyle(fontSize: 12))), + ButtonSegment( + value: 2, + icon: Icon(Icons.timeline_outlined, size: 16), + label: Text('时间线', style: TextStyle(fontSize: 12))), + ], + _noteLayout, + (v) => _setLayout('note', v)), ], ), ); @@ -1303,13 +2299,21 @@ class _LayoutSettingsPageState extends State { void _setLayout(String type, int value) async { switch (type) { - case 'note': await _userPrefs.setNoteLayoutStyle(value); setState(() => _noteLayout = value); - case 'movie': await _userPrefs.setMovieLayoutStyle(value); setState(() => _movieLayout = value); if (mounted) context.read().setMovieLayoutStyle(value); - case 'book': await _userPrefs.setBookLayoutStyle(value); setState(() => _bookLayout = value); + case 'note': + await _userPrefs.setNoteLayoutStyle(value); + setState(() => _noteLayout = value); + case 'movie': + await _userPrefs.setMovieLayoutStyle(value); + setState(() => _movieLayout = value); + if (mounted) context.read().setMovieLayoutStyle(value); + case 'book': + await _userPrefs.setBookLayoutStyle(value); + setState(() => _bookLayout = value); } } - Widget _buildSection(String title, List> segments, int selected, ValueChanged onChanged) { + Widget _buildSection(String title, List> segments, + int selected, ValueChanged onChanged) { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), @@ -1318,7 +2322,11 @@ class _LayoutSettingsPageState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text(title, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface)), + child: Text(title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: colors.onSurface)), ), SegmentedButton( segments: segments, @@ -1326,11 +2334,19 @@ class _LayoutSettingsPageState extends State { onSelectionChanged: (s) => onChanged(s.first), showSelectedIcon: false, style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((s) => s.contains(WidgetState.selected) ? colors.onSurface : colors.surfaceContainerHighest), - foregroundColor: WidgetStateProperty.resolveWith((s) => s.contains(WidgetState.selected) ? colors.surface : colors.onSurface.withValues(alpha: 0.6)), + backgroundColor: WidgetStateProperty.resolveWith((s) => + s.contains(WidgetState.selected) + ? colors.onSurface + : colors.surfaceContainerHighest), + foregroundColor: WidgetStateProperty.resolveWith((s) => + s.contains(WidgetState.selected) + ? colors.surface + : colors.onSurface.withValues(alpha: 0.6)), side: WidgetStateProperty.all(BorderSide.none), - shape: WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), - padding: WidgetStateProperty.all(const EdgeInsets.symmetric(vertical: 10)), + shape: WidgetStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(vertical: 10)), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.standard, ), @@ -1372,8 +2388,18 @@ class _WebViewPageState extends State { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surface, - appBar: AppBar(title: const Text(''), actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: () => _controller.reload())]), - body: Stack(children: [WebViewWidget(controller: _controller), if (_isLoading) Center(child: CircularProgressIndicator(color: colors.onSurface.withValues(alpha: 0.4)))]), + appBar: AppBar(title: const Text(''), actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _controller.reload()) + ]), + body: Stack(children: [ + WebViewWidget(controller: _controller), + if (_isLoading) + Center( + child: CircularProgressIndicator( + color: colors.onSurface.withValues(alpha: 0.4))) + ]), ); } } diff --git a/lib/pages/stroll_page.dart b/lib/pages/stroll_page.dart index 268f364..52bd50d 100644 --- a/lib/pages/stroll_page.dart +++ b/lib/pages/stroll_page.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; -import '../widgets/animated_star_rating.dart'; import '../widgets/fade_in_local_image.dart'; +import '../utils/toast_util.dart'; import 'movies/movie_detail_page.dart'; import 'book/book_detail_page.dart'; import 'note/note_detail_page.dart'; -/// 漫步页面 - 随机发现内容(滑卡形式) +/// 漫步页面 - 随机发现内容 class StrollPage extends StatefulWidget { const StrollPage({super.key}); @@ -20,13 +20,14 @@ class StrollPage extends StatefulWidget { class _StrollPageState extends State { final _random = Random(); final List<_StrollItem> _items = []; + final Set _seenIds = {}; late PageController _pageController; - int _currentIndex = 0; + String _filter = 'all'; // all / movie / book / note @override void initState() { super.initState(); - _pageController = PageController(viewportFraction: 0.85); + _pageController = PageController(viewportFraction: 0.78); _loadBatch(5); } @@ -36,99 +37,123 @@ class _StrollPageState extends State { super.dispose(); } + // ─── 数据加载 ─── + void _loadBatch(int count) { final provider = context.read(); - final movies = provider.movies.where((m) => !m.isDeleted).toList(); - final books = provider.books.where((b) => !b.isDeleted).toList(); - final notes = provider.notes.where((n) => !n.isDeleted).toList(); - final categories = >{}; - if (movies.isNotEmpty) categories['movie'] = movies; - if (books.isNotEmpty) categories['book'] = books; - if (notes.isNotEmpty) categories['note'] = notes; - - if (categories.isEmpty) return; - - final categoryKeys = categories.keys.toList(); - for (int i = 0; i < count; i++) { - final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)]; - _StrollItem? item; - switch (pickedCategory) { - case 'movie': - final m = movies[_random.nextInt(movies.length)]; - item = _StrollItem( - type: 'movie', data: m, - title: m.title, - subtitle: m.alternateTitles.take(2).join(' / '), - detail: _movieDetail(m), - imagePath: m.posterPath, - icon: Icons.movie_outlined, label: '影视', - rating: m.rating, createdAt: m.createdAt, - color: const Color(0xFF4A90D9), - ); - case 'book': - final b = books[_random.nextInt(books.length)]; - item = _StrollItem( - type: 'book', data: b, - title: b.title, - subtitle: b.authors.take(2).join(' / '), - detail: _bookDetail(b), - imagePath: b.coverPath, - icon: Icons.menu_book_outlined, label: '书籍', - rating: b.rating, createdAt: b.createdAt, - color: const Color(0xFF7E57C2), - ); - case 'note': - final n = notes[_random.nextInt(notes.length)]; - item = _StrollItem( - type: 'note', data: n, - title: n.title.isNotEmpty ? n.title : '随手记', - subtitle: n.tags.take(3).join(' · '), - detail: n.content, - imagePath: n.images.isNotEmpty ? n.images.first : null, - icon: Icons.note_outlined, label: '笔记', - createdAt: n.createdAt, - color: const Color(0xFF66BB6A), - ); + // 按类别分池 + final moviePool = <_StrollItem>[]; + final bookPool = <_StrollItem>[]; + final notePool = <_StrollItem>[]; + if (_filter == 'all' || _filter == 'movie') { + for (final m in provider.movies.where((m) => !m.isDeleted)) { + moviePool.add(_StrollItem( + type: 'movie', data: m, id: 'm_${m.id}', + title: m.title, + subtitle: m.alternateTitles.take(2).join(' / '), + detail: _movieDetail(m), + imagePath: m.posterPath, + icon: Icons.movie_outlined, label: '影视', + rating: m.rating, createdAt: m.createdAt, + tags: m.genres.take(3).toList(), + color: const Color(0xFF4A90D9), + )); } - if (item != null) _items.add(item); } + if (_filter == 'all' || _filter == 'book') { + for (final b in provider.books.where((b) => !b.isDeleted)) { + bookPool.add(_StrollItem( + type: 'book', data: b, id: 'b_${b.id}', + title: b.title, + subtitle: b.authors.take(2).join(' / '), + detail: _bookDetail(b), + imagePath: b.coverPath, + icon: Icons.menu_book_outlined, label: '书籍', + rating: b.rating, createdAt: b.createdAt, + tags: b.genres.take(3).toList(), + color: const Color(0xFF7E57C2), + )); + } + } + if (_filter == 'all' || _filter == 'note') { + for (final n in provider.notes.where((n) => !n.isDeleted)) { + notePool.add(_StrollItem( + type: 'note', data: n, id: 'n_${n.id}', + title: n.title.isNotEmpty ? n.title : '随手记', + subtitle: n.tags.take(3).join(' · '), + detail: n.content, + imagePath: n.images.isNotEmpty ? n.images.first : null, + icon: Icons.note_outlined, label: '笔记', + createdAt: n.createdAt, + tags: n.tags.take(3).toList(), + color: const Color(0xFF66BB6A), + )); + } + } + + // 构建非空类别列表 + final pools = >[]; + if (moviePool.isNotEmpty) pools.add(moviePool); + if (bookPool.isNotEmpty) pools.add(bookPool); + if (notePool.isNotEmpty) pools.add(notePool); + if (pools.isEmpty) return; + + // 全部模式下等概率选类别,单类别模式下直接选 + final target = _items.length + count; + int attempts = 0; + while (_items.length < target && attempts < count * 20) { + attempts++; + final pool = _filter == 'all' + ? pools[_random.nextInt(pools.length)] + : pools.first; + final item = _weightedPick(pool); + if (item != null && !_seenIds.contains(item.id)) { + _seenIds.add(item.id); + _items.add(item); + } + } + } + + /// 加权随机:评分越高权重越大 + _StrollItem? _weightedPick(List<_StrollItem> pool) { + if (pool.isEmpty) return null; + final weights = pool.map((item) { + final r = item.rating ?? 5.0; + return r.clamp(1.0, 10.0); + }).toList(); + final total = weights.reduce((a, b) => a + b); + var roll = _random.nextDouble() * total; + for (int i = 0; i < pool.length; i++) { + roll -= weights[i]; + if (roll <= 0) return pool[i]; + } + return pool.last; } void _reshuffle() { setState(() { _items.clear(); - _currentIndex = 0; + _seenIds.clear(); _loadBatch(5); }); } - void _openDetail(_StrollItem item) { - switch (item.type) { - case 'movie': - Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); - case 'book': - Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); - case 'note': - Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note))); - } - } + // ─── 辅助方法 ─── String _movieDetail(Movie m) { final parts = []; - if (m.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / ')); if (m.summary != null && m.summary!.isNotEmpty) { - parts.add(m.summary!.length > 80 ? '${m.summary!.substring(0, 80)}...' : m.summary!); + parts.add(m.summary!.length > 100 ? '${m.summary!.substring(0, 100)}...' : m.summary!); } return parts.join('\n'); } String _bookDetail(Book b) { final parts = []; - if (b.genres.isNotEmpty) parts.add(b.genres.take(3).join(' / ')); if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!); if (b.summary != null && b.summary!.isNotEmpty) { - parts.add(b.summary!.length > 80 ? '${b.summary!.substring(0, 80)}...' : b.summary!); + parts.add(b.summary!.length > 100 ? '${b.summary!.substring(0, 100)}...' : b.summary!); } return parts.join('\n'); } @@ -142,16 +167,39 @@ class _StrollPageState extends State { return '刚刚'; } - String _actionText(_StrollItem item) { - final ago = _timeAgoText(item.createdAt); - switch (item.type) { - case 'movie': return '$ago 看过'; - case 'book': return '$ago 读过'; - case 'note': return '$ago 写下'; - default: return ago; + String _actionVerb(String type) { + switch (type) { + case 'movie': return '看过'; + case 'book': return '读过'; + case 'note': return '写下'; + default: return ''; } } + void _openDetail(_StrollItem item) { + switch (item.type) { + case 'movie': + Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + case 'book': + Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + case 'note': + Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note))); + } + } + + void _deleteItem(_StrollItem item) async { + final provider = context.read(); + switch (item.type) { + case 'movie': await provider.removeMovie(item.data.id); + case 'book': await provider.removeBook(item.data.id); + case 'note': await provider.removeNote(item.data.id); + } + setState(() => _items.remove(item)); + if (mounted) ToastUtil.show(context, '已删除'); + } + + // ─── 界面 ─── + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -159,70 +207,123 @@ class _StrollPageState extends State { return Scaffold( backgroundColor: colors.surface, - appBar: AppBar( - title: const Text('漫步'), - actions: [ - if (hasContent) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Center( - child: Text('${_currentIndex + 1}/${_items.length}', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: _reshuffle, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(18), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.casino_outlined, size: 14, color: colors.onPrimary), - const SizedBox(width: 5), - Text('随机', style: TextStyle(fontSize: 12, color: colors.onPrimary, fontWeight: FontWeight.w500)), - ], - ), - ), - ), + body: Column( + children: [ + // 顶部栏 + _buildTopBar(colors), + // 类型筛选 + _buildFilterBar(colors), + // 内容 + Expanded( + child: !hasContent + ? _buildEmptyState(colors) + : RefreshIndicator( + onRefresh: () async => _reshuffle(), + color: colors.primary, + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + if (index >= _items.length - 2) { + setState(() => _loadBatch(3)); + } + }, + itemCount: _items.length, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _pageController, + builder: (context, child) { + double scale = 1.0; + if (_pageController.hasClients && _pageController.page != null) { + final diff = (_pageController.page! - index).abs(); + scale = (1 - diff * 0.08).clamp(0.88, 1.0); + } + return Transform.scale(scale: scale, child: child); + }, + child: _buildCard(_items[index], colors), + ); + }, + ), + ), ), ], ), - body: !hasContent - ? Center( - child: Text('还没有任何内容\n去添加一些吧', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.3), height: 1.6))) - : PageView.builder( - controller: _pageController, - onPageChanged: (index) { - setState(() => _currentIndex = index); - // 接近末尾时追加新条目 - if (index >= _items.length - 2) { - setState(() => _loadBatch(3)); + ); + } + + Widget _buildTopBar(ColorScheme colors) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.arrow_back_ios_new, size: 20, color: colors.onSurface.withValues(alpha: 0.7)), + ), + const Spacer(), + Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + GestureDetector( + onTap: _reshuffle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(16)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.casino_outlined, size: 14, color: colors.onPrimary), + const SizedBox(width: 4), + Text('随机', style: TextStyle(fontSize: 12, color: colors.onPrimary, fontWeight: FontWeight.w500)), + ]), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFilterBar(ColorScheme colors) { + final filters = [ + ('all', '全部', Icons.apps_outlined), + ('movie', '影视', Icons.movie_outlined), + ('book', '书籍', Icons.menu_book_outlined), + ('note', '笔记', Icons.note_outlined), + ]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: filters.map((f) { + final selected = _filter == f.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () { + if (_filter != f.$1) { + setState(() { + _filter = f.$1; + _items.clear(); + _seenIds.clear(); + _loadBatch(5); + }); } }, - itemCount: _items.length, - itemBuilder: (context, index) { - return AnimatedBuilder( - animation: _pageController, - builder: (context, child) { - double scale = 1.0; - if (_pageController.hasClients && _pageController.page != null) { - final diff = (_pageController.page! - index).abs(); - scale = (1 - diff * 0.1).clamp(0.85, 1.0); - } - return Transform.scale(scale: scale, child: child); - }, - child: _buildCard(_items[index], colors), - ); - }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(f.$3, size: 14, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 4), + Text(f.$2, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ]), + ), ), + ); + }).toList(), + ), ); } @@ -231,125 +332,282 @@ class _StrollPageState extends State { return GestureDetector( onTap: () => _openDetail(item), - child: Container( - margin: const EdgeInsets.symmetric(vertical: 24, horizontal: 6), - decoration: BoxDecoration( - color: colors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 24, offset: const Offset(0, 8)), - ], - ), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - // 封面/图片区域 - if (hasImage) - Expanded( - flex: 5, - child: SizedBox( - width: double.infinity, - child: FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover), - ), - ) - else - Expanded( - flex: 3, - child: Container( - width: double.infinity, - color: colors.surfaceContainerHigh, - child: Center(child: Icon(item.icon, size: 56, color: item.color.withValues(alpha: 0.2))), + onDoubleTap: () => ToastUtil.show(context, '已收藏'), + child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors), + ); + } + + /// 有图片的卡片:全屏沉浸式 + Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 56, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8))], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover), + + // 底部渐变蒙层 + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.85)], + begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.3, 0.7], ), ), + ), + ), - // 内容区域 + // 顶部标签 + 评分 + Positioned( + top: 16, left: 16, right: 16, + child: _buildTopBadges(item), + ), + + // 底部内容 + Positioned( + left: 20, right: 20, bottom: 20, + child: _buildBottomContent(item, Colors.white), + ), + ], + ), + ); + } + + /// 无图片的卡片:内容从顶部开始 + Widget _buildContentCard(_StrollItem item, ColorScheme colors) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 56, horizontal: 8), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: colors.outlineVariant, width: 0.5), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 4))], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部标签 + 评分 + _buildTopBadges(item, textColor: colors.onSurface, bgColor: item.color.withValues(alpha: 0.1)), + const SizedBox(height: 16), + // 内容 Expanded( - flex: 4, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 类型标签 + 评分 - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: item.color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(item.icon, size: 12, color: item.color), - const SizedBox(width: 4), - Text(item.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: item.color)), - ], - ), + // 标签 + if (item.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 6, + children: item.tags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Text(tag, style: TextStyle(fontSize: 11, color: item.color)), + )).toList(), ), - const Spacer(), - if (item.rating != null) - AnimatedStarRating(rating: item.rating!, starSize: 14, showNumber: true), - ], - ), - - const SizedBox(height: 10), + ), // 标题 - Text(item.title, - maxLines: 2, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)), + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)), // 副标题 if (item.subtitle.isNotEmpty) ...[ const SizedBox(height: 4), - Text(item.subtitle, - maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))), ], - const SizedBox(height: 8), - // 详情 - if (item.detail.isNotEmpty) - Expanded( - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: Text(item.detail, maxLines: 3, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6), height: 1.7)), - ), - ), - - const SizedBox(height: 8), - - // 时间 + 点击提示 - Row( - children: [ - Text(_actionText(item), style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.25))), - const Spacer(), - Icon(Icons.arrow_forward_ios, size: 12, color: colors.onSurface.withValues(alpha: 0.15)), - ], - ), + if (item.detail.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(item.detail, maxLines: 6, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.55), height: 1.7)), + ], ], ), ), ), + + const SizedBox(height: 12), + + // 操作栏 + Row(children: [ + Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), + const Spacer(), + _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors), + const SizedBox(width: 8), + _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item), colors: colors), + ]), ], ), ), ); } + + /// 顶部类型标签 + 评分 + Widget _buildTopBadges(_StrollItem item, {Color? textColor, Color? bgColor}) { + final fg = textColor ?? Colors.white; + final bg = bgColor ?? Colors.black.withValues(alpha: 0.3); + return Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(item.icon, size: 14, color: fg), + const SizedBox(width: 4), + Text(item.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)), + ]), + ), + const Spacer(), + if (item.rating != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, size: 14, color: Color(0xFFFFB800)), + const SizedBox(width: 3), + Text(item.rating!.toStringAsFixed(1), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)), + ]), + ), + ]); + } + + /// 底部内容(沉浸式卡片用,白色文字) + Widget _buildBottomContent(_StrollItem item, Color textColor) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (item.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Wrap( + spacing: 6, + children: item.tags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 0.5), + ), + child: Text(tag, style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: 0.8))), + )).toList(), + ), + ), + + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: textColor, height: 1.3)), + + if (item.subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, color: textColor.withValues(alpha: 0.6))), + ], + + if (item.detail.isNotEmpty) ...[ + const SizedBox(height: 10), + Text(item.detail, maxLines: 3, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: textColor.withValues(alpha: 0.5), height: 1.6)), + ], + + const SizedBox(height: 16), + + Row(children: [ + Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}', + style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.4))), + const Spacer(), + _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)), + const SizedBox(width: 12), + _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item)), + ]), + ], + ); + } + + Widget _actionBtn(IconData icon, String label, VoidCallback onTap, {ColorScheme? colors}) { + final fg = colors?.onSurface ?? Colors.white; + final bg = colors != null ? colors.surfaceContainerHighest : Colors.white.withValues(alpha: 0.12); + final border = colors != null ? colors.outlineVariant : Colors.white.withValues(alpha: 0.15); + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: border, width: 0.5), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 14, color: fg.withValues(alpha: 0.8)), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontSize: 12, color: fg.withValues(alpha: 0.8))), + ]), + ), + ); + } + + void _showDeleteConfirm(_StrollItem item) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除"${item.title}"吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () { Navigator.pop(ctx); _deleteItem(item); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + Widget _buildEmptyState(ColorScheme colors) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2))), + const SizedBox(height: 20), + Text('还没有内容', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 8), + Text('去添加一些影视、书籍或笔记吧', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25))), + ], + ), + ); + } } class _StrollItem { final String type; final dynamic data; + final String id; final String title; final String subtitle; final String detail; @@ -358,11 +616,13 @@ class _StrollItem { final String label; final double? rating; final DateTime createdAt; + final List tags; final Color color; _StrollItem({ required this.type, required this.data, + required this.id, required this.title, required this.subtitle, required this.detail, @@ -371,6 +631,7 @@ class _StrollItem { required this.label, this.rating, required this.createdAt, + this.tags = const [], required this.color, }); } diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart index f455dae..289ccbe 100644 --- a/lib/pages/tag_management_page.dart +++ b/lib/pages/tag_management_page.dart @@ -100,11 +100,6 @@ class _TagManagementPageState extends State { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final tags = _tagCache[_currentType] ?? []; - final isSearching = _searchQuery.isNotEmpty; - final usedCount = tags.where((t) => (_usageCounts[t['name']] ?? 0) > 0 && (t['is_hidden'] as int?) != 1).length; - final unusedCount = tags.where((t) => (_usageCounts[t['name']] ?? 0) == 0 && (t['is_hidden'] as int?) != 1).length; - final hiddenCount = tags.where((t) => (t['is_hidden'] as int?) == 1).length; return Scaffold( backgroundColor: colors.surfaceContainerHigh, @@ -114,80 +109,11 @@ class _TagManagementPageState extends State { child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))) : IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags), ]), - body: Column( - children: [ - const SizedBox(height: 8), - // 搜索栏 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: colors.outlineVariant, width: 0.5), - ), - child: Row( - children: [ - const SizedBox(width: 10), - Icon(Icons.search, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), - const SizedBox(width: 6), - Expanded( - child: TextField( - controller: _searchController, - style: TextStyle(fontSize: 13, color: colors.onSurface), - decoration: InputDecoration( - hintText: '搜索标签', - hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - filled: false, - ), - onChanged: (v) => setState(() => _searchQuery = v.trim()), - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: Icon(Icons.close, size: 15, color: colors.onSurface.withValues(alpha: 0.3)), - ), - ), - if (_searchQuery.isEmpty) const SizedBox(width: 10), - ], - ), - ), - ), - const SizedBox(height: 12), - // 统计卡片 - if (!isSearching) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row(children: [ - _buildStatChip(Icons.check_circle_outline, '已使用', usedCount, colors), - const SizedBox(width: 8), - _buildStatChip(Icons.radio_button_unchecked, '未使用', unusedCount, colors), - const SizedBox(width: 8), - _buildStatChip(Icons.visibility_off_outlined, '隐藏', hiddenCount, colors), - ]), - ), - SizedBox(height: isSearching ? 0 : 12), - // 标签列表 - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, - child: _buildTagList(_currentType), - ), - ), - ], + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _buildTagList(_currentType), ), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, @@ -287,21 +213,39 @@ class _TagManagementPageState extends State { Widget _buildTagList(String type) { final tags = _tagCache[type] ?? []; - if (tags.isEmpty) return _buildEmptyState(type); - + final colors = Theme.of(context).colorScheme; final isSearching = _searchQuery.isNotEmpty; + if (tags.isEmpty && !isSearching) { + return SingleChildScrollView( + key: ValueKey('empty_$type'), + padding: const EdgeInsets.fromLTRB(20, 4, 20, 80), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSearchBar(colors), + const SizedBox(height: 40), + _buildEmptyState(type), + ]), + ); + } + // 搜索模式 if (isSearching) { final filtered = tags.where((t) => (t['name'] as String).toLowerCase().contains(_searchQuery.toLowerCase())).toList() ..sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0)); - if (filtered.isEmpty) { - return Center(child: Text('没有找到"$_searchQuery"相关标签', style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)))); - } return SingleChildScrollView( key: ValueKey('search_$type'), - padding: const EdgeInsets.fromLTRB(20, 0, 20, 80), - child: Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()), + padding: const EdgeInsets.fromLTRB(20, 4, 20, 80), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSearchBar(colors), + const SizedBox(height: 12), + if (filtered.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('没有找到"$_searchQuery"相关标签', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + ) + else + Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()), + ]), ); } @@ -316,30 +260,89 @@ class _TagManagementPageState extends State { } used.sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0)); - final colors = Theme.of(context).colorScheme; return SingleChildScrollView( key: ValueKey(type), - padding: const EdgeInsets.fromLTRB(20, 0, 20, 80), + padding: const EdgeInsets.fromLTRB(20, 4, 20, 80), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (used.isNotEmpty) ...[ - _buildGroupHeader('已使用', used.length, colors), - const SizedBox(height: 6), - Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList()), - const SizedBox(height: 16), - ], - if (unused.isNotEmpty) ...[ - _buildGroupHeader('未使用', unused.length, colors), - const SizedBox(height: 6), - Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList()), - const SizedBox(height: 16), - ], - if (hidden.isNotEmpty) ...[ - _buildGroupHeader('隐藏', hidden.length, colors), - const SizedBox(height: 6), - Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList()), - ], + _buildSearchBar(colors), + // 统计栏 + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Row(children: [ + _buildStatChip(Icons.check_circle_outline, '已使用', used.length, colors), + const SizedBox(width: 8), + _buildStatChip(Icons.radio_button_unchecked, '未使用', unused.length, colors), + const SizedBox(width: 8), + _buildStatChip(Icons.visibility_off_outlined, '隐藏', hidden.length, colors), + ]), + ), + _buildGroupHeader('已使用', used.length, colors), + const SizedBox(height: 6), + used.isNotEmpty + ? Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList()) + : _buildEmptyGroup('暂无已使用标签', colors), + const SizedBox(height: 16), + _buildGroupHeader('未使用', unused.length, colors), + const SizedBox(height: 6), + unused.isNotEmpty + ? Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList()) + : _buildEmptyGroup('暂无未使用标签', colors), + const SizedBox(height: 16), + _buildGroupHeader('隐藏', hidden.length, colors), + const SizedBox(height: 6), + hidden.isNotEmpty + ? Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList()) + : _buildEmptyGroup('暂无隐藏标签', colors), + ], + ), + ); + } + + Widget _buildSearchBar(ColorScheme colors) { + return Container( + height: 40, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon(Icons.search_rounded, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchController, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '搜索标签...', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + ), + onChanged: (v) => setState(() => _searchQuery = v.trim()), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, shape: BoxShape.circle), + child: Icon(Icons.close_rounded, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + else + const SizedBox(width: 12), ], ), ); @@ -358,6 +361,13 @@ class _TagManagementPageState extends State { ); } + Widget _buildEmptyGroup(String text, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(text, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), + ); + } + Widget _buildTagChip(Map tag) { final colors = Theme.of(context).colorScheme; final name = tag['name'] as String; @@ -480,6 +490,10 @@ class _TagManagementPageState extends State { await _loadTags(_currentType); }, ), + _menuAction(Icons.open_in_new_outlined, '查看相关${_typeLabels[_currentIndex].replaceAll('类型', '').replaceAll('标签', '')}', colors, () { + Navigator.pop(ctx); + _showTagItems(name); + }), _menuAction(Icons.edit_outlined, '重命名', colors, () { Navigator.pop(ctx); _showRenameDialog(tag); @@ -517,6 +531,77 @@ class _TagManagementPageState extends State { ); } + void _showTagItems(String tagName) { + final provider = context.read(); + final colors = Theme.of(context).colorScheme; + + List<({String title, String? subtitle, String type})> items = []; + if (_currentType == 'movie_genre') { + for (final m in provider.movies.where((m) => !m.isDeleted && m.genres.contains(tagName))) { + items.add((title: m.title, subtitle: m.directors.take(2).join(' / '), type: '影视')); + } + } else if (_currentType == 'book_genre') { + for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) { + items.add((title: b.title, subtitle: b.authors.take(2).join(' / '), type: '书籍')); + } + } else { + for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) { + items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记')); + } + } + + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 24), + children: [ + Center(child: Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text('$tagName(${items.length})', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + const SizedBox(height: 8), + if (items.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('暂无相关内容', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + ) + else + ...items.asMap().entries.map((entry) { + final item = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (entry.key > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.title, style: TextStyle(fontSize: 14, color: colors.onSurface)), + subtitle: item.subtitle != null && item.subtitle!.isNotEmpty + ? Text(item.subtitle!, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))) + : null, + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)), + child: Text(item.type, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + // ─── 添加标签 ────────────────────────────────────────────────────────── void _showAddDialog() { diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 0bd6c44..b1d6039 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -13,6 +13,7 @@ import '../models/reader_book.dart'; import '../utils/database_helper.dart'; import '../utils/image_path_helper.dart'; import '../utils/user_prefs.dart'; +import '../utils/theme/app_theme.dart'; /// 应用全局状态管理 class AppProvider extends ChangeNotifier { @@ -48,6 +49,9 @@ class AppProvider extends ChangeNotifier { // 配色方案索引 int _colorSchemeIndex = 0; + // 字体 + String _fontFamily = ''; + // 观影选中的状态 (0: 已看,1: 想看,2: 在看) int _movieStatusIndex = 0; @@ -119,8 +123,8 @@ class AppProvider extends ChangeNotifier { } // 加载笔记数据 - Future loadNotes() async { - _notes = await _noteDao.getAllNotes(); + Future loadNotes({int sortMode = 0}) async { + _notes = await _noteDao.getAllNotes(sortMode: sortMode); notifyListeners(); } @@ -147,16 +151,16 @@ class AppProvider extends ChangeNotifier { // ─── 分页加载(供列表页触底加载使用)──────────────────────── static const int _pageSize = 20; - Future> loadMoviesPaged({String? status, required int offset}) async { - return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset); + Future> loadMoviesPaged({String? status, required int offset, int sortMode = 0}) async { + return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode); } - Future> loadBooksPaged({String? status, required int offset}) async { - return _bookDao.getBooksPaged(status: status, limit: _pageSize, offset: offset); + Future> loadBooksPaged({String? status, required int offset, int sortMode = 0}) async { + return _bookDao.getBooksPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode); } - Future> loadNotesPaged({required int offset}) async { - return _noteDao.getNotesPaged(limit: _pageSize, offset: offset); + Future> loadNotesPaged({required int offset, int sortMode = 0}) async { + return _noteDao.getNotesPaged(limit: _pageSize, offset: offset, sortMode: sortMode); } // Getters @@ -169,6 +173,7 @@ class AppProvider extends ChangeNotifier { bool get bottomNavVisible => _bottomNavVisible; ThemeMode get themeMode => _themeMode; int get colorSchemeIndex => _colorSchemeIndex; + String get fontFamily => _fontFamily; List get movies => _movies; List get books => _books; List get notes => _notes; @@ -227,6 +232,8 @@ class AppProvider extends ChangeNotifier { _themeMode = ThemeMode.system; } _colorSchemeIndex = prefs.colorSchemeIndex; + _fontFamily = prefs.fontFamily; + AppTheme.setFontFamily(_fontFamily); notifyListeners(); } @@ -238,6 +245,15 @@ class AppProvider extends ChangeNotifier { } } + void setFontFamily(String family) { + if (_fontFamily != family) { + _fontFamily = family; + UserPrefs().setFontFamily(family); + AppTheme.setFontFamily(family); + notifyListeners(); + } + } + void setMovieStatusIndex(int index) { _movieStatusIndex = index; notifyListeners(); @@ -331,6 +347,11 @@ class AppProvider extends ChangeNotifier { await _noteDao.deleteNote(id); await loadNotes(); } + + Future toggleNotePin(String id, bool isPinned) async { + await _noteDao.togglePin(id, isPinned); + await loadNotes(); + } // ========== 影评相关方法 ========== diff --git a/lib/utils/book/book_dao.dart b/lib/utils/book/book_dao.dart index 8f9ca06..a99fa9a 100644 --- a/lib/utils/book/book_dao.dart +++ b/lib/utils/book/book_dao.dart @@ -28,7 +28,7 @@ class BookDao { }); // 分页查询书籍记录 - Future> getBooksPaged({String? status, int limit = 20, int offset = 0}) => _wrap('getBooksPaged', () async { + Future> getBooksPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getBooksPaged', () async { final db = await _dbHelper.database; String where = 'is_deleted = 0'; List whereArgs = []; @@ -37,10 +37,18 @@ class BookDao { whereArgs.add(status); } final maps = await db.query('books', where: where, whereArgs: whereArgs, - orderBy: 'created_at DESC', limit: limit, offset: offset); + orderBy: _buildBookOrderBy(sortMode), limit: limit, offset: offset); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); }); + static String _buildBookOrderBy(int sortMode) { + switch (sortMode) { + case 1: return 'created_at DESC'; + case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + default: return 'updated_at DESC'; + } + } + // 根据状态筛选书籍记录 Future> getBooksByStatus(String status) => _wrap('getBooksByStatus', () async { final db = await _dbHelper.database; diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 0b09e7c..58c6b14 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -39,7 +39,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 21, + version: 22, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -152,6 +152,10 @@ class DatabaseHelper { } } catch (_) {} } + if (oldVersion < 22) { + // 为笔记表添加置顶字段 + await _upgradeNotesTableV22(db); + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -191,6 +195,15 @@ class DatabaseHelper { } } + /// 升级notes表到V22(添加置顶字段) + Future _upgradeNotesTableV22(Database db) async { + final columns = await db.rawQuery('PRAGMA table_info(notes)'); + final hasIsPinned = columns.any((col) => col['name'] == 'is_pinned'); + if (!hasIsPinned) { + await db.execute('ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0'); + } + } + /// 升级到V14:创建阅读器书籍表 Future _createReaderBooksTable(Database db) async { await db.execute(''' @@ -574,7 +587,8 @@ class DatabaseHelper { images TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - is_deleted INTEGER DEFAULT 0 + is_deleted INTEGER DEFAULT 0, + is_pinned INTEGER NOT NULL DEFAULT 0 ) '''); diff --git a/lib/utils/movie/movie_dao.dart b/lib/utils/movie/movie_dao.dart index e69bc1e..c6d2590 100644 --- a/lib/utils/movie/movie_dao.dart +++ b/lib/utils/movie/movie_dao.dart @@ -28,7 +28,7 @@ class MovieDao { }); // 分页查询影视记录 - Future> getMoviesPaged({String? status, int limit = 20, int offset = 0}) => _wrap('getMoviesPaged', () async { + Future> getMoviesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async { final db = await _dbHelper.database; String where = 'is_deleted = 0'; List whereArgs = []; @@ -37,10 +37,18 @@ class MovieDao { whereArgs.add(status); } final maps = await db.query('movies', where: where, whereArgs: whereArgs, - orderBy: 'created_at DESC', limit: limit, offset: offset); + orderBy: _buildMovieOrderBy(sortMode), limit: limit, offset: offset); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); }); + static String _buildMovieOrderBy(int sortMode) { + switch (sortMode) { + case 1: return 'created_at DESC'; + case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + default: return 'updated_at DESC'; + } + } + // 根据状态筛选影视记录 Future> getMoviesByStatus(String status) => _wrap('getMoviesByStatus', () async { final db = await _dbHelper.database; diff --git a/lib/utils/note/note_dao.dart b/lib/utils/note/note_dao.dart index 7ffc178..8e598df 100644 --- a/lib/utils/note/note_dao.dart +++ b/lib/utils/note/note_dao.dart @@ -16,25 +16,34 @@ class NoteDao { } // 获取所有未删除的笔记 - Future> getAllNotes() => _wrap('getAllNotes', () async { + Future> getAllNotes({int sortMode = 0}) => _wrap('getAllNotes', () async { final db = await _dbHelper.database; final List> maps = await db.query( 'notes', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'created_at DESC', + orderBy: _buildOrderBy(sortMode), ); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); }); // 分页查询笔记 - Future> getNotesPaged({int limit = 20, int offset = 0}) => _wrap('getNotesPaged', () async { + Future> getNotesPaged({int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getNotesPaged', () async { final db = await _dbHelper.database; final maps = await db.query('notes', where: 'is_deleted = 0', - orderBy: 'created_at DESC', limit: limit, offset: offset); + orderBy: _buildOrderBy(sortMode), limit: limit, offset: offset); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); }); + /// 根据排序模式生成 ORDER BY 子句,置顶始终排最前 + static String _buildOrderBy(int sortMode) { + switch (sortMode) { + case 1: return 'is_pinned DESC, created_at DESC'; + case 2: return 'is_pinned DESC, title COLLATE NOCASE ASC'; + default: return 'is_pinned DESC, updated_at DESC'; + } + } + // 根据ID获取笔记 Future getNoteById(String id) => _wrap('getNoteById', () async { final db = await _dbHelper.database; @@ -75,6 +84,17 @@ class NoteDao { ); }); + // 切换笔记置顶状态 + Future togglePin(String id, bool isPinned) => _wrap('togglePin', () async { + final db = await _dbHelper.database; + return await db.update( + 'notes', + {'is_pinned': isPinned ? 1 : 0, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + // ========== 回收站相关方法 ========== // 获取已删除的笔记 diff --git a/lib/utils/sync/auto_backup_service.dart b/lib/utils/sync/auto_backup_service.dart index 7662032..0548775 100644 --- a/lib/utils/sync/auto_backup_service.dart +++ b/lib/utils/sync/auto_backup_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:shared_preferences/shared_preferences.dart'; @@ -67,7 +68,7 @@ class AutoBackupService { try { final backupDir = await _getBackupDirectory(); if (backupDir == null) { - print('AutoBackup: 无法获取备份目录'); + debugPrint('AutoBackup: 无法获取备份目录'); return; } @@ -80,7 +81,7 @@ class AutoBackupService { final result = await BackupService.instance.exportDataForAutoBackup(); if (!result.success) { - print('AutoBackup: 导出失败 - ${result.errorMessage}'); + debugPrint('AutoBackup: 导出失败 - ${result.errorMessage}'); return; } @@ -91,13 +92,13 @@ class AutoBackupService { // 写入备份文件 await backupFile.writeAsBytes(result.zipBytes!); - print('AutoBackup: 备份成功 - ${backupFile.path}'); + debugPrint('AutoBackup: 备份成功 - ${backupFile.path}'); - // 清理旧备份,只保留最新的10个 + // 清理旧备份,只保留最新的5个 await _cleanupOldBackups(backupDir); } catch (e) { - print('AutoBackup: 备份失败 - $e'); + debugPrint('AutoBackup: 备份失败 - $e'); } } @@ -134,12 +135,12 @@ class AutoBackupService { return downloadDir; } catch (e) { - print('AutoBackup: 获取备份目录失败 - $e'); + debugPrint('AutoBackup: 获取备份目录失败 - $e'); return null; } } - /// 清理旧备份,只保留最新的10个 + /// 清理旧备份,只保留最新的5个 Future _cleanupOldBackups(Directory backupDir) async { try { final files = await backupDir @@ -160,14 +161,14 @@ class AutoBackupService { for (var i = _maxBackups; i < files.length; i++) { try { await files[i].delete(); - print('AutoBackup: 删除旧备份 - ${files[i].path}'); + debugPrint('AutoBackup: 删除旧备份 - ${files[i].path}'); } catch (e) { - print('AutoBackup: 删除旧备份失败 - $e'); + debugPrint('AutoBackup: 删除旧备份失败 - $e'); } } } } catch (e) { - print('AutoBackup: 清理旧备份失败 - $e'); + debugPrint('AutoBackup: 清理旧备份失败 - $e'); } } @@ -194,7 +195,7 @@ class AutoBackupService { return files; } catch (e) { - print('AutoBackup: 获取备份列表失败 - $e'); + debugPrint('AutoBackup: 获取备份列表失败 - $e'); return []; } } diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index 1388cd1..8ce1559 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:share_plus/share_plus.dart'; @@ -14,422 +14,247 @@ import '../user_prefs.dart'; /// 数据备份服务 - 支持导出和导入数据(包含图片) class BackupService { static final BackupService instance = BackupService._init(); - + BackupService._init(); - + + // ─── 共享导出逻辑 ───────────────────────────────────── + + /// 收集所有表数据和图片,构建 ZIP 字节 + Future<_ExportData> _buildExportData() async { + final db = await DatabaseHelper.instance.database; + + final movies = await db.query('movies'); + final books = await db.query('books'); + final notes = await db.query('notes'); + final movieReviews = await db.query('movie_reviews'); + final moviePosters = await db.query('movie_posters'); + final bookReviews = await db.query('book_reviews'); + final bookExcerpts = await db.query('book_excerpts'); + final tags = await db.query('tags'); + + // 收集图片路径 + final imagePaths = {}; + for (final m in movies) { + final p = m['poster_path'] as String?; + if (p != null && p.isNotEmpty) imagePaths.add(p); + } + for (final b in books) { + final p = b['cover_path'] as String?; + if (p != null && p.isNotEmpty) imagePaths.add(p); + } + for (final p in moviePosters) { + final pp = p['poster_path'] as String?; + if (pp != null && pp.isNotEmpty) imagePaths.add(pp); + } + for (final n in notes) { + final imagesJson = n['images'] as String?; + if (imagesJson != null && imagesJson.isNotEmpty) { + try { + for (final ip in jsonDecode(imagesJson) as List) { + if (ip is String && ip.isNotEmpty) imagePaths.add(ip); + } + } catch (e) { + debugPrint('[BackupService] 笔记图片解析失败 (noteId=${n['id']}): $e'); + } + } + } + + final userPrefs = UserPrefs(); + final userInfo = { + 'nickname': userPrefs.nickname, + 'motto': userPrefs.motto, + 'avatarPath': userPrefs.avatarPath, + }; + final avatarPath = userPrefs.avatarPath; + if (avatarPath != null && avatarPath.isNotEmpty) imagePaths.add(avatarPath); + + // 构建备份数据 + final backupData = { + 'version': 2, + 'exportTime': DateTime.now().toIso8601String(), + 'appName': 'MookNote', + 'hasImages': true, + 'userInfo': userInfo, + 'data': { + 'movies': movies, + 'books': books, + 'notes': notes, + 'movie_reviews': movieReviews, + 'movie_posters': moviePosters, + 'book_reviews': bookReviews, + 'book_excerpts': bookExcerpts, + 'tags': tags, + }, + }; + + // 创建 ZIP + final archive = Archive(); + final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); + final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); + archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); + + int imageCount = 0; + final appDir = await getApplicationDocumentsDirectory(); + final imagesRoot = path.join(appDir.path, 'images'); + + for (final imagePath in imagePaths) { + final file = File(imagePath); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + String relativePath; + if (imagePath.startsWith(imagesRoot)) { + relativePath = imagePath.substring(imagesRoot.length + 1); + } else { + relativePath = path.basename(imagePath); + } + archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes)); + imageCount++; + } + } + + final zipBytes = ZipEncoder().encode(archive); + if (zipBytes == null) throw Exception('压缩备份文件失败'); + + return _ExportData( + zipBytes: Uint8List.fromList(zipBytes), + movieCount: movies.length, + bookCount: books.length, + noteCount: notes.length, + imageCount: imageCount, + ); + } + + // ─── 手动导出 ───────────────────────────────────────── + /// 导出所有数据和图片为 ZIP 文件,并选择保存路径 Future exportDataWithImages() async { try { - final db = await DatabaseHelper.instance.database; - - // 导出所有表的数据 - final movies = await db.query('movies'); - final books = await db.query('books'); - final notes = await db.query('notes'); - final movieReviews = await db.query('movie_reviews'); - final moviePosters = await db.query('movie_posters'); - final bookReviews = await db.query('book_reviews'); - final bookExcerpts = await db.query('book_excerpts'); - final tags = await db.query('tags'); - - // 收集所有图片路径 - final imagePaths = {}; - - // 收集影视海报 - for (final movie in movies) { - final posterPath = movie['poster_path'] as String?; - if (posterPath != null && posterPath.isNotEmpty) { - imagePaths.add(posterPath); - } - } - - // 收集书籍封面 - for (final book in books) { - final coverPath = book['cover_path'] as String?; - if (coverPath != null && coverPath.isNotEmpty) { - imagePaths.add(coverPath); - } - } - - // 收集海报墙图片 - for (final poster in moviePosters) { - final posterPath = poster['poster_path'] as String?; - if (posterPath != null && posterPath.isNotEmpty) { - imagePaths.add(posterPath); - } - } - - // 收集笔记图片 - for (final note in notes) { - final imagesJson = note['images'] as String?; - if (imagesJson != null && imagesJson.isNotEmpty) { - try { - final images = jsonDecode(imagesJson) as List; - for (final imagePath in images) { - if (imagePath is String && imagePath.isNotEmpty) { - imagePaths.add(imagePath); - } - } - } catch (e) { - // 解析失败,跳过 - } - } - } - - // 获取用户个人信息 - final userPrefs = UserPrefs(); - final userInfo = { - 'nickname': userPrefs.nickname, - 'motto': userPrefs.motto, - 'avatarPath': userPrefs.avatarPath, - }; - - // 如果有头像,添加到图片路径 - final avatarPath = userPrefs.avatarPath; - if (avatarPath != null && avatarPath.isNotEmpty) { - imagePaths.add(avatarPath); - } - - // 构建备份数据 - final backupData = { - 'version': 2, - 'exportTime': DateTime.now().toIso8601String(), - 'appName': 'MookNote', - 'hasImages': true, - 'userInfo': userInfo, - 'data': { - 'movies': movies, - 'books': books, - 'notes': notes, - 'movie_reviews': movieReviews, - 'movie_posters': moviePosters, - 'book_reviews': bookReviews, - 'book_excerpts': bookExcerpts, - 'tags': tags, - }, - }; - - // 创建 ZIP 文件 - final archive = Archive(); - - // 添加 JSON 数据 - final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); - final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); - archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); - - // 添加图片文件,保持目录结构 - int imageCount = 0; - final appDir = await getApplicationDocumentsDirectory(); - final imagesRoot = path.join(appDir.path, 'images'); - - for (final imagePath in imagePaths) { - final file = File(imagePath); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - // 计算相对路径(如 movies/1/poster.jpg) - String relativePath; - if (imagePath.startsWith(imagesRoot)) { - relativePath = imagePath.substring(imagesRoot.length + 1); // +1 去掉开头的 / - } else { - relativePath = path.basename(imagePath); - } - // 使用相对路径存储图片,保持目录结构 - archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes)); - imageCount++; - } - } - - // 压缩 ZIP - final zipEncoder = ZipEncoder(); - final zipBytes = zipEncoder.encode(archive); - if (zipBytes == null) { - return ExportResult.error('压缩备份文件失败'); - } - - // 保存到临时目录 + final data = await _buildExportData(); final tempDir = await getTemporaryDirectory(); final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip'; final tempFilePath = path.join(tempDir.path, fileName); - final tempFile = File(tempFilePath); - await tempFile.writeAsBytes(zipBytes); - - // 在移动端使用分享功能,让用户选择保存位置 - // 在桌面端可以尝试使用 saveFile + await File(tempFilePath).writeAsBytes(data.zipBytes); + String? finalPath; - try { - // 尝试使用系统保存对话框(桌面端支持) final outputPath = await FilePicker.platform.saveFile( dialogTitle: '保存备份文件', fileName: fileName, type: FileType.custom, allowedExtensions: ['zip'], - bytes: Uint8List.fromList(zipBytes), // 在移动端需要提供 bytes + bytes: data.zipBytes, ); - if (outputPath == null) { - // 用户取消,返回临时文件路径 - finalPath = tempFilePath; - } else { - finalPath = outputPath; - // 如果保存路径不是临时文件路径,需要复制过去 - if (finalPath != tempFilePath) { - final outputFile = File(finalPath); - await outputFile.writeAsBytes(zipBytes); - } + return ExportResult.cancelled(); + } + finalPath = outputPath; + if (finalPath != tempFilePath) { + await File(finalPath).writeAsBytes(data.zipBytes); } } catch (e) { - // 如果 saveFile 失败,使用临时文件路径 finalPath = tempFilePath; } - + return ExportResult.success( filePath: finalPath, - movieCount: movies.length, - bookCount: books.length, - noteCount: notes.length, - imageCount: imageCount, + movieCount: data.movieCount, + bookCount: data.bookCount, + noteCount: data.noteCount, + imageCount: data.imageCount, ); } catch (e) { return ExportResult.error('导出失败: $e'); } } - + /// 分享备份文件 Future shareBackup(String filePath) async { final file = XFile(filePath); - await Share.shareXFiles( - [file], - subject: 'MookNote 数据备份', - text: '这是我的 MookNote 数据备份文件', - ); + await Share.shareXFiles([file], subject: 'MookNote 数据备份', text: '这是我的 MookNote 数据备份文件'); } - - /// 导出数据用于自动备份(返回字节数据而不是保存到文件) + + // ─── 自动备份导出 ───────────────────────────────────── + + /// 导出数据用于自动备份(返回字节数据) Future exportDataForAutoBackup() async { try { - final db = await DatabaseHelper.instance.database; - - // 导出所有表的数据 - final movies = await db.query('movies'); - final books = await db.query('books'); - final notes = await db.query('notes'); - final movieReviews = await db.query('movie_reviews'); - final moviePosters = await db.query('movie_posters'); - final bookReviews = await db.query('book_reviews'); - final bookExcerpts = await db.query('book_excerpts'); - final tags = await db.query('tags'); - - // 收集所有图片路径 - final imagePaths = {}; - - // 收集影视海报 - for (final movie in movies) { - final posterPath = movie['poster_path'] as String?; - if (posterPath != null && posterPath.isNotEmpty) { - imagePaths.add(posterPath); - } - } - - // 收集书籍封面 - for (final book in books) { - final coverPath = book['cover_path'] as String?; - if (coverPath != null && coverPath.isNotEmpty) { - imagePaths.add(coverPath); - } - } - - // 收集海报墙图片 - for (final poster in moviePosters) { - final posterPath = poster['poster_path'] as String?; - if (posterPath != null && posterPath.isNotEmpty) { - imagePaths.add(posterPath); - } - } - - // 收集笔记图片 - for (final note in notes) { - final imagesJson = note['images'] as String?; - if (imagesJson != null && imagesJson.isNotEmpty) { - try { - final images = jsonDecode(imagesJson) as List; - for (final imagePath in images) { - if (imagePath is String && imagePath.isNotEmpty) { - imagePaths.add(imagePath); - } - } - } catch (e) { - // 解析失败,跳过 - } - } - } - - // 获取用户个人信息 - final userPrefs = UserPrefs(); - final userInfo = { - 'nickname': userPrefs.nickname, - 'motto': userPrefs.motto, - 'avatarPath': userPrefs.avatarPath, - }; - - // 如果有头像,添加到图片路径 - final avatarPath = userPrefs.avatarPath; - if (avatarPath != null && avatarPath.isNotEmpty) { - imagePaths.add(avatarPath); - } - - // 构建备份数据 - final backupData = { - 'version': 2, - 'exportTime': DateTime.now().toIso8601String(), - 'appName': 'MookNote', - 'hasImages': true, - 'userInfo': userInfo, - 'data': { - 'movies': movies, - 'books': books, - 'notes': notes, - 'movie_reviews': movieReviews, - 'movie_posters': moviePosters, - 'book_reviews': bookReviews, - 'book_excerpts': bookExcerpts, - 'tags': tags, - }, - }; - - // 创建 ZIP 文件 - final archive = Archive(); - - // 添加 JSON 数据 - final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); - final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); - archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); - - // 添加图片文件,保持目录结构 - int imageCount = 0; - final appDir = await getApplicationDocumentsDirectory(); - final imagesRoot = path.join(appDir.path, 'images'); - - for (final imagePath in imagePaths) { - final file = File(imagePath); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - // 计算相对路径(如 movies/1/poster.jpg) - String relativePath; - if (imagePath.startsWith(imagesRoot)) { - relativePath = imagePath.substring(imagesRoot.length + 1); - } else { - relativePath = path.basename(imagePath); - } - // 使用相对路径存储图片,保持目录结构 - archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes)); - imageCount++; - } - } - - // 压缩 ZIP - final zipEncoder = ZipEncoder(); - final zipBytes = zipEncoder.encode(archive); - if (zipBytes == null) { - return AutoBackupExportResult.error('压缩备份文件失败'); - } - + final data = await _buildExportData(); return AutoBackupExportResult.success( - zipBytes: Uint8List.fromList(zipBytes), - movieCount: movies.length, - bookCount: books.length, - noteCount: notes.length, - imageCount: imageCount, + zipBytes: data.zipBytes, + movieCount: data.movieCount, + bookCount: data.bookCount, + noteCount: data.noteCount, + imageCount: data.imageCount, ); } catch (e) { return AutoBackupExportResult.error('导出失败: $e'); } } - - /// 选择并导入备份文件(支持 ZIP 格式) + + // ─── 导入 ───────────────────────────────────────────── + + /// 选择并导入备份文件(支持 ZIP 和旧版 JSON) Future importData() async { try { - // 选择文件 final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['zip', 'json'], allowMultiple: false, ); - - if (result == null || result.files.isEmpty) { - return ImportResult.cancelled(); - } - + + if (result == null || result.files.isEmpty) return ImportResult.cancelled(); + final filePath = result.files.first.path; - if (filePath == null) { - return ImportResult.error('无法读取文件路径'); - } - + if (filePath == null) return ImportResult.error('无法读取文件路径'); + final file = File(filePath); final extension = path.extension(filePath).toLowerCase(); - + Map backupData; int imageCount = 0; - // 记录图片文件名到新路径的映射 + // 完整相对路径 → 新绝对路径 的映射(避免同名文件碰撞) final imagePathMap = {}; - + if (extension == '.zip') { - // 处理 ZIP 文件 final bytes = await file.readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); - - // 查找 data.json + final dataFile = archive.findFile('data.json'); - if (dataFile == null) { - return ImportResult.error('备份文件中没有找到数据文件'); - } - - final jsonString = utf8.decode(dataFile.content as List); - backupData = jsonDecode(jsonString) as Map; - - // 解压图片到应用目录,保持目录结构 + if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件'); + + backupData = jsonDecode(utf8.decode(dataFile.content as List)) as Map; + final appDir = await getApplicationDocumentsDirectory(); final imagesDir = Directory(path.join(appDir.path, 'images')); - if (!await imagesDir.exists()) { - await imagesDir.create(recursive: true); - } - + if (!await imagesDir.exists()) await imagesDir.create(recursive: true); + for (final archiveFile in archive) { if (archiveFile.name.startsWith('images/')) { - // 获取相对路径(如 movies/1/poster.jpg) - final relativePath = archiveFile.name.substring(7); // 去掉 'images/' 前缀 + final relativePath = archiveFile.name.substring(7); final outputFile = File(path.join(imagesDir.path, relativePath)); - - // 确保父目录存在 - final parentDir = outputFile.parent; - if (!await parentDir.exists()) { - await parentDir.create(recursive: true); - } - + if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true); await outputFile.writeAsBytes(archiveFile.content as List); - - // 记录文件名到新路径的映射(用于更新数据库中的路径) - final fileName = path.basename(archiveFile.name); - imagePathMap[fileName] = outputFile.path; + // 用完整相对路径做 key,避免不同目录下同名文件碰撞 + imagePathMap[relativePath] = outputFile.path; imageCount++; } } } else { - // 处理旧版 JSON 文件 - final jsonString = await file.readAsString(); - backupData = jsonDecode(jsonString) as Map; + // 旧版 JSON + backupData = jsonDecode(await file.readAsString()) as Map; } - - // 验证备份格式 - if (!backupData.containsKey('data')) { - return ImportResult.error('无效的备份文件格式'); + + if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式'); + + // 验证版本 + final version = backupData['version'] as int? ?? 1; + if (version > 2) { + debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2,部分数据可能丢失'); } - - // 导入数据 + final data = backupData['data'] as Map; final db = await DatabaseHelper.instance.database; - - // 预获取各表的列名(用于过滤不存在的列) + final moviesCols = await _getTableColumns(db, 'movies'); final booksCols = await _getTableColumns(db, 'books'); final notesCols = await _getTableColumns(db, 'notes'); @@ -439,9 +264,7 @@ class BackupService { final bookExcerptsCols = await _getTableColumns(db, 'book_excerpts'); final tagsCols = await _getTableColumns(db, 'tags'); - // 开始事务 await db.transaction((txn) async { - // 清空现有数据 await txn.delete('movie_reviews'); await txn.delete('movie_posters'); await txn.delete('book_reviews'); @@ -451,73 +274,44 @@ class BackupService { await txn.delete('notes'); await txn.delete('tags'); - // 导入影视数据(更新图片路径) if (data.containsKey('movies')) { - final movies = data['movies'] as List; - for (final movie in movies) { - final movieMap = _convertToDbMapSafe(movie, moviesCols); - final updatedMap = _updateImagePath(movieMap, 'poster_path', imagePathMap); - await txn.insert('movies', updatedMap); + for (final m in data['movies'] as List) { + await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap)); } } - - // 导入书籍数据(更新图片路径) if (data.containsKey('books')) { - final books = data['books'] as List; - for (final book in books) { - final bookMap = _convertToDbMapSafe(book, booksCols); - final updatedMap = _updateImagePath(bookMap, 'cover_path', imagePathMap); - await txn.insert('books', updatedMap); + for (final b in data['books'] as List) { + await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap)); } } - - // 导入笔记数据(更新图片路径) if (data.containsKey('notes')) { - final notes = data['notes'] as List; - for (final note in notes) { - final noteMap = _convertToDbMapSafe(note, notesCols); - final updatedMap = _updateNoteImagesPath(noteMap, imagePathMap); - await txn.insert('notes', updatedMap); + for (final n in data['notes'] as List) { + await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap)); } } - - // 导入影评数据 if (data.containsKey('movie_reviews')) { - final reviews = data['movie_reviews'] as List; - for (final review in reviews) { - await txn.insert('movie_reviews', _convertToDbMapSafe(review, movieReviewsCols)); + for (final r in data['movie_reviews'] as List) { + await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols)); } } - - // 导入海报墙数据(更新图片路径) if (data.containsKey('movie_posters')) { - final posters = data['movie_posters'] as List; - for (final poster in posters) { - final posterMap = _convertToDbMapSafe(poster, moviePostersCols); - final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap); - await txn.insert('movie_posters', updatedMap); + for (final p in data['movie_posters'] as List) { + await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap)); } } - // 导入书评数据 if (data.containsKey('book_reviews')) { - final reviews = data['book_reviews'] as List; - for (final review in reviews) { - await txn.insert('book_reviews', _convertToDbMapSafe(review, bookReviewsCols)); + for (final r in data['book_reviews'] as List) { + await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols)); } } - // 导入书摘数据 if (data.containsKey('book_excerpts')) { - final excerpts = data['book_excerpts'] as List; - for (final excerpt in excerpts) { - await txn.insert('book_excerpts', _convertToDbMapSafe(excerpt, bookExcerptsCols)); + for (final e in data['book_excerpts'] as List) { + await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols)); } } - - // 导入标签数据 if (data.containsKey('tags')) { - final tags = data['tags'] as List; - for (final tag in tags) { - final map = _convertToDbMapSafe(tag, tagsCols); + for (final t in data['tags'] as List) { + final map = _convertToDbMapSafe(t, tagsCols); await txn.rawInsert( 'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)', [map['id'], map['name'], map['type'], map['created_at']], @@ -526,109 +320,51 @@ class BackupService { } }); - // 恢复用户个人信息 - if (backupData.containsKey('userInfo')) { - final userInfo = backupData['userInfo'] as Map; - final userPrefs = UserPrefs(); - - if (userInfo.containsKey('nickname')) { - await userPrefs.setNickname(userInfo['nickname'] as String); - } - if (userInfo.containsKey('motto')) { - await userPrefs.setMotto(userInfo['motto'] as String); - } - if (userInfo.containsKey('avatarPath')) { - final avatarPath = userInfo['avatarPath'] as String?; - if (avatarPath != null && avatarPath.isNotEmpty) { - // 更新头像路径为新的路径 - final fileName = path.basename(avatarPath); - if (imagePathMap.containsKey(fileName)) { - await userPrefs.setAvatarPath(imagePathMap[fileName]!); - } - } - } - } - - // 统计导入数量 - final stats = {}; - if (data.containsKey('movies')) { - stats['影视'] = (data['movies'] as List).length; - } - if (data.containsKey('books')) { - stats['书籍'] = (data['books'] as List).length; - } - if (data.containsKey('notes')) { - stats['笔记'] = (data['notes'] as List).length; - } - if (data.containsKey('movie_reviews')) { - stats['影评'] = (data['movie_reviews'] as List).length; - } - if (data.containsKey('movie_posters')) { - stats['海报'] = (data['movie_posters'] as List).length; - } - if (data.containsKey('tags')) { - stats['标签'] = (data['tags'] as List).length; - } - if (imageCount > 0) { - stats['图片'] = imageCount; - } - - return ImportResult.success(stats); + // 恢复用户信息 + await _restoreUserInfo(backupData, imagePathMap); + + return ImportResult.success(_buildStats(data, imageCount)); } catch (e) { return ImportResult.error('导入失败: $e'); } } - + /// 从 ZIP 字节数据恢复(供 WebDAV 同步等场景使用) Future restoreFromZipBytes(Uint8List zipBytes) async { try { final archive = ZipDecoder().decodeBytes(zipBytes); - - // 查找 data.json final dataFile = archive.findFile('data.json'); - if (dataFile == null) { - return ImportResult.error('备份文件中没有找到数据文件'); - } + if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件'); - final jsonString = utf8.decode(dataFile.content as List); - final backupData = jsonDecode(jsonString) as Map; - - // 记录图片文件名到新路径的映射 + final backupData = jsonDecode(utf8.decode(dataFile.content as List)) as Map; final imagePathMap = {}; int imageCount = 0; - // 解压图片到应用目录,保持目录结构 final appDir = await getApplicationDocumentsDirectory(); final imagesDir = Directory(path.join(appDir.path, 'images')); - if (!await imagesDir.exists()) { - await imagesDir.create(recursive: true); - } + if (!await imagesDir.exists()) await imagesDir.create(recursive: true); for (final archiveFile in archive) { if (archiveFile.name.startsWith('images/')) { - final relativePath = archiveFile.name.substring(7); // 去掉 'images/' 前缀 + final relativePath = archiveFile.name.substring(7); final outputFile = File(path.join(imagesDir.path, relativePath)); - final parentDir = outputFile.parent; - if (!await parentDir.exists()) { - await parentDir.create(recursive: true); - } + if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true); await outputFile.writeAsBytes(archiveFile.content as List); - final fileName = path.basename(archiveFile.name); - imagePathMap[fileName] = outputFile.path; + imagePathMap[relativePath] = outputFile.path; imageCount++; } } - // 验证备份格式 - if (!backupData.containsKey('data')) { - return ImportResult.error('无效的备份文件格式'); + if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式'); + + final version = backupData['version'] as int? ?? 1; + if (version > 2) { + debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2,部分数据可能丢失'); } - // 导入数据 final data = backupData['data'] as Map; final db = await DatabaseHelper.instance.database; - // 预获取各表的列名 final moviesCols = await _getTableColumns(db, 'movies'); final booksCols = await _getTableColumns(db, 'books'); final notesCols = await _getTableColumns(db, 'notes'); @@ -639,7 +375,6 @@ class BackupService { final tagsCols = await _getTableColumns(db, 'tags'); await db.transaction((txn) async { - // 清空现有数据 await txn.delete('movie_reviews'); await txn.delete('movie_posters'); await txn.delete('book_reviews'); @@ -647,69 +382,46 @@ class BackupService { await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); + await txn.delete('tags'); // 修复: 之前漏删 tags 表 if (data.containsKey('movies')) { - final movies = data['movies'] as List; - for (final movie in movies) { - final movieMap = _convertToDbMapSafe(movie, moviesCols); - final updatedMap = _updateImagePath(movieMap, 'poster_path', imagePathMap); - await txn.insert('movies', updatedMap); + for (final m in data['movies'] as List) { + await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap)); } } - if (data.containsKey('books')) { - final books = data['books'] as List; - for (final book in books) { - final bookMap = _convertToDbMapSafe(book, booksCols); - final updatedMap = _updateImagePath(bookMap, 'cover_path', imagePathMap); - await txn.insert('books', updatedMap); + for (final b in data['books'] as List) { + await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap)); } } - if (data.containsKey('notes')) { - final notes = data['notes'] as List; - for (final note in notes) { - final noteMap = _convertToDbMapSafe(note, notesCols); - final updatedMap = _updateNoteImagesPath(noteMap, imagePathMap); - await txn.insert('notes', updatedMap); + for (final n in data['notes'] as List) { + await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap)); } } - if (data.containsKey('movie_reviews')) { - final reviews = data['movie_reviews'] as List; - for (final review in reviews) { - await txn.insert('movie_reviews', _convertToDbMapSafe(review, movieReviewsCols)); + for (final r in data['movie_reviews'] as List) { + await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols)); } } - if (data.containsKey('movie_posters')) { - final posters = data['movie_posters'] as List; - for (final poster in posters) { - final posterMap = _convertToDbMapSafe(poster, moviePostersCols); - final updatedMap = _updateImagePath(posterMap, 'poster_path', imagePathMap); - await txn.insert('movie_posters', updatedMap); + for (final p in data['movie_posters'] as List) { + await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap)); } } - // 导入书评数据 if (data.containsKey('book_reviews')) { - final reviews = data['book_reviews'] as List; - for (final review in reviews) { - await txn.insert('book_reviews', _convertToDbMapSafe(review, bookReviewsCols)); + for (final r in data['book_reviews'] as List) { + await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols)); } } - // 导入书摘数据 if (data.containsKey('book_excerpts')) { - final excerpts = data['book_excerpts'] as List; - for (final excerpt in excerpts) { - await txn.insert('book_excerpts', _convertToDbMapSafe(excerpt, bookExcerptsCols)); + for (final e in data['book_excerpts'] as List) { + await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols)); } } - - // 导入标签数据 if (data.containsKey('tags')) { - final tags = data['tags'] as List; - for (final tag in tags) { - final map = _convertToDbMapSafe(tag, tagsCols); + for (final t in data['tags'] as List) { + final map = _convertToDbMapSafe(t, tagsCols); await txn.rawInsert( 'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)', [map['id'], map['name'], map['type'], map['created_at']], @@ -718,159 +430,140 @@ class BackupService { } }); - // 恢复用户个人信息 - if (backupData.containsKey('userInfo')) { - final userInfo = backupData['userInfo'] as Map; - final userPrefs = UserPrefs(); - - if (userInfo.containsKey('nickname')) { - await userPrefs.setNickname(userInfo['nickname'] as String); - } - if (userInfo.containsKey('motto')) { - await userPrefs.setMotto(userInfo['motto'] as String); - } - if (userInfo.containsKey('avatarPath')) { - final avatarPath = userInfo['avatarPath'] as String?; - if (avatarPath != null && avatarPath.isNotEmpty) { - final fileName = path.basename(avatarPath); - if (imagePathMap.containsKey(fileName)) { - await userPrefs.setAvatarPath(imagePathMap[fileName]!); - } - } - } - } - - // 统计导入数量 - final stats = {}; - if (data.containsKey('movies')) stats['影视'] = (data['movies'] as List).length; - if (data.containsKey('books')) stats['书籍'] = (data['books'] as List).length; - if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length; - if (data.containsKey('movie_reviews')) stats['影评'] = (data['movie_reviews'] as List).length; - if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] as List).length; - if (data.containsKey('book_reviews')) stats['书评'] = (data['book_reviews'] as List).length; - if (data.containsKey('book_excerpts')) stats['书摘'] = (data['book_excerpts'] as List).length; - if (imageCount > 0) stats['图片'] = imageCount; - - return ImportResult.success(stats); + await _restoreUserInfo(backupData, imagePathMap); + return ImportResult.success(_buildStats(data, imageCount)); } catch (e) { return ImportResult.error('恢复失败: $e'); } } - /// 将动态类型转换为数据库可用的 Map,并过滤掉表中不存在的列 + // ─── 内部辅助方法 ───────────────────────────────────── + + Future _restoreUserInfo(Map backupData, Map imagePathMap) async { + if (!backupData.containsKey('userInfo')) return; + final userInfo = backupData['userInfo'] as Map; + final userPrefs = UserPrefs(); + + if (userInfo.containsKey('nickname')) await userPrefs.setNickname(userInfo['nickname'] as String); + if (userInfo.containsKey('motto')) await userPrefs.setMotto(userInfo['motto'] as String); + if (userInfo.containsKey('avatarPath')) { + final avatarPath = userInfo['avatarPath'] as String?; + if (avatarPath != null && avatarPath.isNotEmpty) { + final relPath = _toRelativePath(avatarPath); + if (imagePathMap.containsKey(relPath)) { + await userPrefs.setAvatarPath(imagePathMap[relPath]!); + } + } + } + } + + Map _buildStats(Map data, int imageCount) { + final stats = {}; + if (data.containsKey('movies')) stats['影视'] = (data['movies'] as List).length; + if (data.containsKey('books')) stats['书籍'] = (data['books'] as List).length; + if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length; + if (data.containsKey('movie_reviews')) stats['影评'] = (data['movie_reviews'] as List).length; + if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] as List).length; + if (data.containsKey('book_reviews')) stats['书评'] = (data['book_reviews'] as List).length; + if (data.containsKey('book_excerpts')) stats['书摘'] = (data['book_excerpts'] as List).length; + if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length; + if (imageCount > 0) stats['图片'] = imageCount; + return stats; + } + + /// 将绝对路径转为 images/ 下的相对路径(用于 imagePathMap key) + String _toRelativePath(String absolutePath) { + // 尝试提取 images/ 后面的部分 + final idx = absolutePath.indexOf('/images/'); + if (idx >= 0) return absolutePath.substring(idx + 8); // skip '/images/' + // Windows 路径 + final winIdx = absolutePath.indexOf('\\images\\'); + if (winIdx >= 0) return absolutePath.substring(winIdx + 8); + return path.basename(absolutePath); + } + Map _convertToDbMapSafe(dynamic item, Set validColumns) { final raw = _convertToDbMap(item); if (raw.isEmpty) return raw; return Map.fromEntries(raw.entries.where((e) => validColumns.contains(e.key))); } - /// 获取表的列名集合 Future> _getTableColumns(Database db, String table) async { final columns = await db.rawQuery('PRAGMA table_info($table)'); return columns.map((c) => c['name'] as String).toSet(); } - /// 将动态类型转换为数据库可用的 Map Map _convertToDbMap(dynamic item) { if (item is Map) { return item.map((key, value) { - // 处理布尔值 - if (value is bool) { - return MapEntry(key, value ? 1 : 0); - } + if (value is bool) return MapEntry(key, value ? 1 : 0); return MapEntry(key, value); }); } return {}; } - - /// 更新图片路径为新的路径 - /// 支持新的存储结构:images/movies/{id}/、images/books/{id}/、images/notes/{id}/ - Map _updateImagePath( - Map item, - String pathField, - Map imagePathMap, - ) { + + /// 更新单值图片路径(poster_path / cover_path) + Map _updateImagePath(Map item, String pathField, Map imagePathMap) { final newItem = Map.from(item); final oldPath = item[pathField] as String?; - if (oldPath != null && oldPath.isNotEmpty) { - final fileName = path.basename(oldPath); - // 如果图片在映射中,更新路径 - if (imagePathMap.containsKey(fileName)) { - newItem[pathField] = imagePathMap[fileName]; - } else { - // 尝试从旧版备份中恢复(旧版只保存了文件名) - // 检查是否有匹配的文件名(不区分目录结构) - for (final entry in imagePathMap.entries) { - if (path.basename(entry.key) == fileName) { - newItem[pathField] = entry.value; - break; - } - } + final relPath = _toRelativePath(oldPath); + if (imagePathMap.containsKey(relPath)) { + newItem[pathField] = imagePathMap[relPath]; } } - return newItem; } - - /// 更新笔记图片路径为新的路径 - /// 支持新的存储结构:images/notes/{id}/ - Map _updateNoteImagesPath( - Map item, - Map imagePathMap, - ) { + + /// 更新笔记多图路径(images JSON 列表) + Map _updateNoteImagesPath(Map item, Map imagePathMap) { final newItem = Map.from(item); final imagesJson = item['images'] as String?; - - if (imagesJson != null && imagesJson.isNotEmpty) { - try { - final images = jsonDecode(imagesJson) as List; - final updatedImages = []; - - for (final imagePath in images) { - if (imagePath is String && imagePath.isNotEmpty) { - final fileName = path.basename(imagePath); - // 如果图片在映射中,更新路径 - if (imagePathMap.containsKey(fileName)) { - updatedImages.add(imagePathMap[fileName]!); - } else { - // 尝试从旧版备份中恢复(旧版只保存了文件名) - bool found = false; - for (final entry in imagePathMap.entries) { - if (path.basename(entry.key) == fileName) { - updatedImages.add(entry.value); - found = true; - break; - } - } - if (!found) { - updatedImages.add(imagePath); - } - } - } + if (imagesJson == null || imagesJson.isEmpty) return newItem; + + try { + final images = jsonDecode(imagesJson) as List; + final updatedImages = []; + for (final imagePath in images) { + if (imagePath is String && imagePath.isNotEmpty) { + final relPath = _toRelativePath(imagePath); + updatedImages.add(imagePathMap[relPath] ?? imagePath); } - - newItem['images'] = jsonEncode(updatedImages); - } catch (e) { - // 解析失败,保持原样 } + newItem['images'] = jsonEncode(updatedImages); + } catch (e) { + debugPrint('[BackupService] 笔记图片路径更新失败: $e'); } - return newItem; } - - /// 格式化日期时间用于文件名 + String _formatDateTime(DateTime dateTime) { return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}'; } - - String _pad(int number) { - return number.toString().padLeft(2, '0'); - } + + String _pad(int number) => number.toString().padLeft(2, '0'); } -/// 自动备份导出结果 +/// 导出中间数据 +class _ExportData { + final Uint8List zipBytes; + final int movieCount; + final int bookCount; + final int noteCount; + final int imageCount; + + _ExportData({ + required this.zipBytes, + required this.movieCount, + required this.bookCount, + required this.noteCount, + required this.imageCount, + }); +} + +// ─── 结果类型 ────────────────────────────────────────── + class AutoBackupExportResult { final bool success; final String? errorMessage; @@ -879,7 +572,7 @@ class AutoBackupExportResult { final int bookCount; final int noteCount; final int imageCount; - + AutoBackupExportResult._({ required this.success, this.errorMessage, @@ -889,7 +582,7 @@ class AutoBackupExportResult { this.noteCount = 0, this.imageCount = 0, }); - + factory AutoBackupExportResult.success({ required Uint8List zipBytes, required int movieCount, @@ -898,21 +591,17 @@ class AutoBackupExportResult { required int imageCount, }) { return AutoBackupExportResult._( - success: true, - zipBytes: zipBytes, - movieCount: movieCount, - bookCount: bookCount, - noteCount: noteCount, - imageCount: imageCount, + success: true, zipBytes: zipBytes, + movieCount: movieCount, bookCount: bookCount, + noteCount: noteCount, imageCount: imageCount, ); } - + factory AutoBackupExportResult.error(String message) { return AutoBackupExportResult._(success: false, errorMessage: message); } } -/// 导出结果 class ExportResult { final bool success; final bool cancelled; @@ -922,7 +611,7 @@ class ExportResult { final int bookCount; final int noteCount; final int imageCount; - + ExportResult._({ required this.success, this.cancelled = false, @@ -933,7 +622,7 @@ class ExportResult { this.noteCount = 0, this.imageCount = 0, }); - + factory ExportResult.success({ required String filePath, required int movieCount, @@ -942,55 +631,48 @@ class ExportResult { required int imageCount, }) { return ExportResult._( - success: true, - filePath: filePath, - movieCount: movieCount, - bookCount: bookCount, - noteCount: noteCount, - imageCount: imageCount, + success: true, filePath: filePath, + movieCount: movieCount, bookCount: bookCount, + noteCount: noteCount, imageCount: imageCount, ); } - + factory ExportResult.cancelled() { return ExportResult._(success: false, cancelled: true); } - + factory ExportResult.error(String message) { return ExportResult._(success: false, errorMessage: message); } } -/// 导入结果 class ImportResult { final bool success; final bool cancelled; final String? errorMessage; final Map? stats; - + ImportResult._({ required this.success, this.cancelled = false, this.errorMessage, this.stats, }); - + factory ImportResult.success(Map stats) { return ImportResult._(success: true, stats: stats); } - + factory ImportResult.cancelled() { return ImportResult._(success: false, cancelled: true); } - + factory ImportResult.error(String message) { return ImportResult._(success: false, errorMessage: message); } - - /// 获取统计信息文本 + String get statsText { - if (stats == null || stats!.isEmpty) { - return '没有导入任何数据'; - } + if (stats == null || stats!.isEmpty) return '没有导入任何数据'; return stats!.entries.map((e) => '${e.key}: ${e.value}').join(','); } } diff --git a/lib/utils/theme/app_theme.dart b/lib/utils/theme/app_theme.dart index a760a28..127eb49 100644 --- a/lib/utils/theme/app_theme.dart +++ b/lib/utils/theme/app_theme.dart @@ -26,8 +26,9 @@ class AppTheme { static const Color wantToReadColor = Color(0xFF999999); // 想读 - 浅灰 static const Color readingColor = Color(0xFF666666); // 在读 - 中灰 - // 字体配置 - static const String _fontFamily = 'Inter'; + // 字体配置(空字符串 = 系统默认) + static String _fontFamily = ''; + static void setFontFamily(String value) => _fontFamily = value; // 字重 static const FontWeight _regular = FontWeight.w400; @@ -46,8 +47,18 @@ class AppTheme { static const List colorSchemeNames = ['经典', '靛蓝', '薄荷', '琥珀', '玫瑰', '紫罗兰']; - /// 根据配色索引获取浅色主题 - static ThemeData getLightTheme(int index) { + // 莫奈动态取色 + static Color? _monetColor; + static void setMonetColor(Color? color) => _monetColor = color; + static Color? get monetColor => _monetColor; + + /// 根据配色索引获取浅色主题(index -1 = 莫奈自动取色) + static ThemeData getLightTheme(int index, {Color? monetColor}) { + if (index == -1) { + final c = monetColor ?? _monetColor; + if (c != null) return _buildColoredLightTheme(c); + return lightTheme; + } if (index <= 0) return lightTheme; return _buildColoredLightTheme(seedColors[index]); } @@ -114,7 +125,7 @@ class AppTheme { selectedItemColor: scheme.primary, unselectedItemColor: scheme.onSurfaceVariant, elevation: 0, type: BottomNavigationBarType.fixed, - selectedLabelStyle: const TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium), + selectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium), unselectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _regular, color: scheme.onSurfaceVariant), ), ); @@ -144,7 +155,7 @@ class AppTheme { ), // AppBar - 极简无边框 - appBarTheme: const AppBarTheme( + appBarTheme: AppBarTheme( backgroundColor: _white, foregroundColor: _black, elevation: 0, @@ -244,7 +255,7 @@ class AppTheme { ), // 底部导航 - bottomNavigationBarTheme: const BottomNavigationBarThemeData( + bottomNavigationBarTheme: BottomNavigationBarThemeData( backgroundColor: _white, selectedItemColor: _black, unselectedItemColor: _lightGray, @@ -263,7 +274,7 @@ class AppTheme { ), // 文字主题 - textTheme: const TextTheme( + textTheme: TextTheme( // 大标题 headlineLarge: TextStyle( fontFamily: _fontFamily, @@ -357,7 +368,7 @@ class AppTheme { outlineVariant: _darkGray, ), - appBarTheme: const AppBarTheme( + appBarTheme: AppBarTheme( backgroundColor: _black, foregroundColor: _white, elevation: 0, @@ -442,7 +453,7 @@ class AppTheme { ), ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( + bottomNavigationBarTheme: BottomNavigationBarThemeData( backgroundColor: _black, selectedItemColor: _white, unselectedItemColor: _gray, @@ -460,7 +471,7 @@ class AppTheme { ), ), - textTheme: const TextTheme( + textTheme: TextTheme( headlineLarge: TextStyle( fontFamily: _fontFamily, fontSize: 32, diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index d32710f..8531571 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -51,6 +51,10 @@ class UserPrefs { int get colorSchemeIndex => prefs.getInt('colorSchemeIndex') ?? 0; Future setColorSchemeIndex(int value) => prefs.setInt('colorSchemeIndex', value); + /// 字体: 空字符串=系统默认 + String get fontFamily => prefs.getString('fontFamily') ?? ''; + Future setFontFamily(String value) => prefs.setString('fontFamily', value); + /// 上映日期:显示到日(true)/ 显示到月(false) bool get showExactReleaseDate => prefs.getBool('showExactReleaseDate') ?? true; Future setShowExactReleaseDate(bool value) => prefs.setBool('showExactReleaseDate', value); @@ -101,6 +105,18 @@ class UserPrefs { int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; Future setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); + /// 笔记排序方式 (0: 更新时间, 1: 创建时间) + int get noteSortMode => prefs.getInt('noteSortMode') ?? 0; + Future setNoteSortMode(int value) => prefs.setInt('noteSortMode', value); + + /// 影视排序方式 (0: 更新时间, 1: 创建时间, 2: 评分) + int get movieSortMode => prefs.getInt('movieSortMode') ?? 0; + Future setMovieSortMode(int value) => prefs.setInt('movieSortMode', value); + + /// 书籍排序方式 (0: 更新时间, 1: 创建时间, 2: 评分) + int get bookSortMode => prefs.getInt('bookSortMode') ?? 0; + Future setBookSortMode(int value) => prefs.setInt('bookSortMode', value); + /// 影视布局样式 (0: 海报网格, 1: 列表) int get movieLayoutStyle => prefs.getInt('movieLayoutStyle') ?? 0; Future setMovieLayoutStyle(int value) => prefs.setInt('movieLayoutStyle', value); diff --git a/lib/widgets/animated_star_rating.dart b/lib/widgets/animated_star_rating.dart index 86a3c80..369c260 100644 --- a/lib/widgets/animated_star_rating.dart +++ b/lib/widgets/animated_star_rating.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -/// 带动画的星级评分组件 — 星星依次亮起 + 数字滚动 -class AnimatedStarRating extends StatefulWidget { +/// 静态星级评分组件 +class AnimatedStarRating extends StatelessWidget { final double rating; final double starSize; final Color color; @@ -15,48 +15,10 @@ class AnimatedStarRating extends StatefulWidget { this.showNumber = false, }); - @override - State createState() => _AnimatedStarRatingState(); -} - -class _AnimatedStarRatingState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late List> _animations; - late Animation _numberAnim; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - _animations = List.generate(5, (i) { - return CurvedAnimation( - parent: _controller, - curve: Interval(i * 0.12, (i * 0.12) + 0.35, curve: Curves.easeOutBack), - ); - }); - _numberAnim = Tween(begin: 0, end: widget.rating).animate( - CurvedAnimation( - parent: _controller, - curve: const Interval(0.1, 0.8, curve: Curves.easeOut), - ), - ); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final starValue = widget.rating / 2; + final starValue = rating / 2; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -70,25 +32,17 @@ class _AnimatedStarRatingState extends State } else { iconData = Icons.star_border; } - return ScaleTransition( - scale: _animations[index], - child: Icon(iconData, size: widget.starSize, color: widget.color), - ); + return Icon(iconData, size: starSize, color: color); }), - if (widget.showNumber) ...[ + if (showNumber) ...[ const SizedBox(width: 4), - AnimatedBuilder( - animation: _numberAnim, - builder: (context, child) { - return Text( - _numberAnim.value.toStringAsFixed(1), - style: TextStyle( - fontSize: widget.starSize, - fontWeight: FontWeight.w600, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ); - }, + Text( + rating.toStringAsFixed(1), + style: TextStyle( + fontSize: starSize, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.6), + ), ), ], ], diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 448dd19..206982f 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -5,6 +5,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; import '../pages/stroll_page.dart'; +import '../pages/media_calendar_page.dart'; +import '../pages/person_list_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; import '../pages/tag_management_page.dart'; import '../pages/reader/bookshelf_page.dart'; @@ -173,6 +175,16 @@ class _CustomDrawerState extends State { Navigator.push(context, MaterialPageRoute(builder: (_) => const StrollPage())); }, topRounded: true), Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant), + _buildToolItem(Icons.calendar_month_outlined, '书影日历', () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const MediaCalendarPage())); + }), + Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant), + _buildToolItem(Icons.people_outline, '角色信息', () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const PersonListPage())); + }), + Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant), _buildToolItem(Icons.label_outline, '标签管理', () { Navigator.pop(context); Navigator.push(context, MaterialPageRoute(builder: (_) => const TagManagementPage())); @@ -252,8 +264,8 @@ class _CustomDrawerState extends State { const totalWeeks = 20; const weekDays = 7; - const cellSize = 13.0; - const cellGap = 3.0; + const cellSize = 10.0; + const cellGap = 1.5; final cells = List.generate(weekDays, (_) => List.generate(totalWeeks, (_) => 0)); for (int week = 0; week < totalWeeks; week++) { @@ -289,12 +301,10 @@ class _CustomDrawerState extends State { ], ), const SizedBox(height: 14), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ SizedBox( height: 16, child: Row( @@ -320,7 +330,6 @@ class _CustomDrawerState extends State { )), ], ), - ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/lib/widgets/genre_selector_page.dart b/lib/widgets/genre_selector_page.dart index c02fa24..7926e16 100644 --- a/lib/widgets/genre_selector_page.dart +++ b/lib/widgets/genre_selector_page.dart @@ -1,18 +1,55 @@ import 'package:flutter/material.dart'; -/// 类型/标签选择全屏页(影视类型、书籍类型通用) +/// 类型/标签选择右侧弹窗(影视类型、导演、编剧、主演、书籍类型通用) class GenreSelectorPage extends StatefulWidget { final String title; - final List existingTags; + final List? existingTags; + final Future>? existingTagsFuture; final List initialSelected; final String hint; const GenreSelectorPage({ super.key, required this.title, - required this.existingTags, + this.existingTags, + this.existingTagsFuture, required this.initialSelected, this.hint = '', - }); + }) : assert(existingTags != null || existingTagsFuture != null, 'existingTags 和 existingTagsFuture 至少提供一个'); + + /// 显示右侧弹窗 + static Future?> show({ + required BuildContext context, + required String title, + List? existingTags, + Future>? existingTagsFuture, + required List initialSelected, + String hint = '', + }) { + return showGeneralDialog>( + context: context, + barrierDismissible: true, + barrierLabel: 'selector-panel', + barrierColor: Colors.black.withValues(alpha: 0.3), + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (_, __, ___) => const SizedBox.shrink(), + transitionBuilder: (ctx, anim, secAnim, child) { + return SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: Align( + alignment: Alignment.centerRight, + child: GenreSelectorPage( + title: title, + existingTags: existingTags, + existingTagsFuture: existingTagsFuture, + initialSelected: initialSelected, + hint: hint, + ), + ), + ); + }, + ); + } @override State createState() => _GenreSelectorPageState(); @@ -21,12 +58,24 @@ class GenreSelectorPage extends StatefulWidget { class _GenreSelectorPageState extends State { late List _selected; final _controller = TextEditingController(); - String _newTag = ''; + String _query = ''; + List? _loadedTags; + bool _loading = true; @override void initState() { super.initState(); _selected = List.from(widget.initialSelected); + _loadTags(); + } + + Future _loadTags() async { + if (widget.existingTags != null) { + _loadedTags = widget.existingTags; + } else { + _loadedTags = await widget.existingTagsFuture; + } + if (mounted) setState(() => _loading = false); } @override @@ -46,11 +95,11 @@ class _GenreSelectorPageState extends State { } void _addCustom() { - final tag = _newTag.trim(); + final tag = _query.trim(); if (tag.isNotEmpty && !_selected.contains(tag)) { setState(() { _selected.add(tag); - _newTag = ''; + _query = ''; _controller.clear(); }); } @@ -59,118 +108,163 @@ class _GenreSelectorPageState extends State { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final available = widget.existingTags.where((t) => !_selected.contains(t)).toList(); + final screenWidth = MediaQuery.of(context).size.width; + final allTags = _loadedTags ?? []; + final query = _query.toLowerCase(); + final available = allTags + .where((t) => !_selected.contains(t)) + .where((t) => query.isEmpty || t.toLowerCase().contains(query)) + .toList(); - return Scaffold( - backgroundColor: colors.surface, - appBar: AppBar( - title: Text(widget.title), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, _selected), - child: Text('完成', style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, - )), - ), - const SizedBox(width: 8), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + return Material( + color: colors.surface, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: screenWidth * 0.75, + height: double.infinity, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 已选择 - if (_selected.isNotEmpty) ...[ - Text('已选择', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), - const SizedBox(height: 8), - Wrap( - spacing: 8, runSpacing: 8, - children: _selected.map((tag) { - final displayTag = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; - return GestureDetector( - onTap: () => _toggle(tag), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: colors.primary, borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(displayTag, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)), - const SizedBox(width: 6), - Icon(Icons.close, size: 14, color: colors.onPrimary.withValues(alpha: 0.7)), - ], - ), - ), - ); - }).toList(), + // Header + Container( + padding: EdgeInsets.fromLTRB(16, MediaQuery.of(context).padding.top + 12, 8, 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), ), - const SizedBox(height: 24), - ], - // 自定义输入 - Text('自定义添加', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - style: TextStyle(fontSize: 14, color: colors.onSurface), - decoration: InputDecoration( - hintText: widget.hint.isNotEmpty ? widget.hint : '输入自定义类型', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), - filled: true, fillColor: colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), - ), - onChanged: (v) => setState(() => _newTag = v), - onSubmitted: (_) => _addCustom(), + child: Row( + children: [ + Text(widget.title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + TextButton( + onPressed: () => Navigator.pop(context, _selected), + child: Text('完成', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)), ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _addCustom, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _newTag.trim().isNotEmpty ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.add, size: 20, - color: _newTag.trim().isNotEmpty ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.3)), - ), - ), - ], + ], + ), ), - // 已有类型 - if (available.isNotEmpty) ...[ - const SizedBox(height: 24), - Text('已有类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), - const SizedBox(height: 8), - Wrap( - spacing: 8, runSpacing: 8, - children: available.map((tag) { - final displayTag = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; - return GestureDetector( - onTap: () => _toggle(tag), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 4), - Text(displayTag, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), - ], - ), - ), - ); - }).toList(), + + // 已选择(一行一个,最新在上) + if (_selected.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text('已选择', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.25), + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: _selected.length, + itemBuilder: (_, i) { + final tag = _selected[_selected.length - 1 - i]; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Container( + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + leading: Icon(Icons.check_circle, size: 20, color: colors.primary), + title: Text(tag, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), + trailing: GestureDetector( + onTap: () => _toggle(tag), + child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ), + ), + ); + }, + ), + ), + ), + ], + + // 搜索/输入框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: TextField( + controller: _controller, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: widget.hint.isNotEmpty ? '搜索或${widget.hint}' : '搜索或输入', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + suffixIcon: IconButton( + icon: Icon(Icons.add, size: 20, + color: _query.trim().isNotEmpty ? colors.primary : colors.onSurface.withValues(alpha: 0.25)), + onPressed: _addCustom, + ), + ), + onChanged: (v) => setState(() => _query = v), + onSubmitted: (_) => _addCustom(), + ), + ), + + // 已有类型/搜索结果 + if (allTags.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _loading ? '加载中...' : (query.isEmpty ? '已有类型' : '匹配结果'), + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ), + ), + Expanded( + child: _loading + ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + : available.isEmpty + ? Center(child: Text( + query.isEmpty ? '暂无已有选项' : '无匹配结果,回车添加', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), + )) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: available.map((tag) { + return GestureDetector( + onTap: () => _toggle(tag), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline, width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 4), + Text(tag, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), + ], + ), + ), + ); + }).toList(), + ), + ), ), ], ], diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index d08f59d..c963807 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -1,9 +1,7 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; -import '../utils/toast_util.dart'; import 'fade_in_local_image.dart'; /// 笔记列表项组件 - 极简主义设计 @@ -29,13 +27,12 @@ class _NoteListItemContent extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return InkWell( - onTap: () { + onTap: () async { final provider = context.read(); - Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { - await provider.loadNotes(); - }); + await Navigator.pushNamed(context, '/note-detail', arguments: note); + await provider.loadNotes(); }, - onLongPress: () => _showDeleteDialog(context), + onLongPress: () => _showActions(context), child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), @@ -48,6 +45,7 @@ class _NoteListItemContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // 日期行 Row( children: [ Text( @@ -74,6 +72,10 @@ class _NoteListItemContent extends StatelessWidget { ), ), ), + if (note.isPinned) ...[ + const SizedBox(width: 6), + Icon(Icons.push_pin, size: 12, color: colors.primary), + ], ], ), @@ -106,6 +108,44 @@ class _NoteListItemContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), + // 图片缩略图(最多3张,超出显示 +N) + if (note.images.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + for (int i = 0; i < note.images.length.clamp(0, 3); i++) ...[ + if (i > 0) const SizedBox(width: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + FadeInLocalImage( + path: note.images[i], + width: 56, height: 56, + fit: BoxFit.cover, + errorWidget: Container(width: 56, height: 56, color: colors.surfaceContainerHighest), + ), + // 第3张且有更多时显示 +N + if (i == 2 && note.images.length > 3) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text('+${note.images.length - 3}', + style: const TextStyle(fontSize: 13, color: Colors.white, fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ], + ], + ), + ], + if (note.tags.isNotEmpty) ...[ const SizedBox(height: 6), Wrap( @@ -130,56 +170,80 @@ class _NoteListItemContent extends StatelessWidget { }).toList(), ), ], - - if (note.images.isNotEmpty) ...[ - const SizedBox(height: 8), - _buildImagePreviewRow(colors), - ], ], ), ), ); } - Widget _buildImagePreviewRow(ColorScheme colors) { - final images = note.images; - final count = images.length.clamp(0, 3); - return Row( - children: [ - for (int i = 0; i < count; i++) - Container( - width: 48, - height: 48, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all(color: colors.outlineVariant, width: 0.5), - ), - clipBehavior: Clip.antiAlias, - child: FadeInLocalImage( - path: images[i], - fit: BoxFit.cover, - errorWidget: Container( - color: colors.surfaceContainerHighest, - ), - ), + void _showActions(BuildContext context) { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + 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: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 16), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(note.isPinned ? Icons.push_pin_outlined : Icons.push_pin, size: 20, color: colors.onSurface.withValues(alpha: 0.6))), + title: Text(note.isPinned ? '取消置顶' : '置顶', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + subtitle: Text(note.isPinned ? '取消置顶后按时间排序' : '置顶后始终显示在最前', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + context.read().toggleNotePin(note.id, !note.isPinned); + }, ), - if (images.length > 3) - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - '+${images.length - 3}', - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4), fontWeight: FontWeight.w500), - ), - ), + Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.delete_outline, size: 20, color: colors.error)), + title: Text('删除', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.error)), + subtitle: Text('删除后可在回收站恢复', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + _showDeleteConfirm(context); + }, ), - ], + const SizedBox(height: 12), + ]), + ), + ); + } + + void _showDeleteConfirm(BuildContext context) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除这条笔记吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () async { + await context.read().removeNote(note.id); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), ); } @@ -199,62 +263,6 @@ class _NoteListItemContent extends StatelessWidget { String _collapseBlankLines(String text) { return text.replaceAll(RegExp(r'\n\s*\n+'), '\n'); } - - void _showDeleteDialog(BuildContext context) { - final colors = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text( - '确认删除', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - content: Text( - '确定要删除这条笔记吗?删除后可在回收站恢复。', - style: TextStyle( - fontSize: 14, - color: colors.onSurface.withValues(alpha: 0.6), - height: 1.5, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: colors.onSurface.withValues(alpha: 0.6), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () async { - await context.read().removeNote(note.id); - Navigator.pop(context); - ToastUtil.show(context, '已删除'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.error, - foregroundColor: colors.onError, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('删除'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ); - } } String _formatDate(DateTime date) { @@ -262,7 +270,6 @@ String _formatDate(DateTime date) { final difference = now.difference(date); if (difference.isNegative) { - // 服务端时间比本地快(时钟偏差),显示绝对日期 return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } if (difference.inDays == 0) { diff --git a/lib/widgets/tag_side_panel.dart b/lib/widgets/tag_side_panel.dart new file mode 100644 index 0000000..a50417b --- /dev/null +++ b/lib/widgets/tag_side_panel.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; + +/// 标签侧边面板 - 从右侧滑入,用于选择和新建标签 +class TagSidePanel extends StatefulWidget { + final List selectedTags; + final List allAvailableTags; + final ValueChanged> onTagsChanged; + + const TagSidePanel({ + super.key, + required this.selectedTags, + required this.allAvailableTags, + required this.onTagsChanged, + }); + + static Future show({ + required BuildContext context, + required List selectedTags, + required List allAvailableTags, + required ValueChanged> onTagsChanged, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'tag-panel', + barrierColor: Colors.black.withValues(alpha: 0.3), + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (_, __, ___) => const SizedBox.shrink(), + transitionBuilder: (ctx, anim, secAnim, child) { + return SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: Align( + alignment: Alignment.centerRight, + child: TagSidePanel( + selectedTags: selectedTags, + allAvailableTags: allAvailableTags, + onTagsChanged: onTagsChanged, + ), + ), + ); + }, + ); + } + + @override + State createState() => _TagSidePanelState(); +} + +class _TagSidePanelState extends State { + late List _selected; + final _inputController = TextEditingController(); + + @override + void initState() { + super.initState(); + _selected = List.from(widget.selectedTags); + } + + @override + void dispose() { + _inputController.dispose(); + super.dispose(); + } + + void _toggleTag(String tag) { + setState(() { + if (_selected.contains(tag)) { + _selected.remove(tag); + } else { + _selected.add(tag); + } + }); + widget.onTagsChanged(_selected); + } + + void _addNewTag() { + final tag = _inputController.text.trim(); + if (tag.isEmpty) return; + if (!_selected.contains(tag)) { + setState(() => _selected.add(tag)); + widget.onTagsChanged(_selected); + } + _inputController.clear(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + return Material( + color: colors.surface, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: screenWidth * 0.75, + height: double.infinity, + child: Column( + children: [ + // Header + Container( + padding: EdgeInsets.fromLTRB(16, MediaQuery.of(context).padding.top + 12, 8, 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row( + children: [ + Text('标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.close, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + ), + ], + ), + ), + + // 新建标签输入框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: TextField( + controller: _inputController, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '输入新标签,回车添加', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colors.primary, width: 1), + ), + suffixIcon: IconButton( + icon: Icon(Icons.add, size: 18, color: colors.primary), + onPressed: _addNewTag, + ), + ), + onSubmitted: (_) => _addNewTag(), + ), + ), + + // 已选标签 + if (_selected.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text('已选标签', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _selected.map((tag) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(16), + ), + child: GestureDetector( + onTap: () => _toggleTag(tag), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tag, style: TextStyle(fontSize: 13, color: colors.onPrimary)), + const SizedBox(width: 4), + Icon(Icons.close, size: 14, color: colors.onPrimary), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + + // 全部标签 + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text('全部标签', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: widget.allAvailableTags.map((tag) { + final isSelected = _selected.contains(tag); + return GestureDetector( + onTap: () => _toggleTag(tag), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? colors.primary.withValues(alpha: 0.15) : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? colors.primary : colors.outline, + width: 0.5, + ), + ), + child: Text( + tag, + style: TextStyle( + fontSize: 13, + color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.7), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/text_input_panel.dart b/lib/widgets/text_input_panel.dart new file mode 100644 index 0000000..0a52cb6 --- /dev/null +++ b/lib/widgets/text_input_panel.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +/// 右侧滑入文本输入弹窗(单行编辑用,如影视名称、书籍名称) +class TextInputPanel extends StatefulWidget { + final String title; + final String initialValue; + final String hint; + final TextInputType keyboardType; + + const TextInputPanel({ + super.key, + required this.title, + this.initialValue = '', + this.hint = '', + this.keyboardType = TextInputType.text, + }); + + static Future show({ + required BuildContext context, + required String title, + String initialValue = '', + String hint = '', + TextInputType keyboardType = TextInputType.text, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'text-input-panel', + barrierColor: Colors.black.withValues(alpha: 0.3), + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (_, __, ___) => const SizedBox.shrink(), + transitionBuilder: (ctx, anim, secAnim, child) { + return SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: Align( + alignment: Alignment.centerRight, + child: TextInputPanel( + title: title, + initialValue: initialValue, + hint: hint, + keyboardType: keyboardType, + ), + ), + ); + }, + ); + } + + @override + State createState() => _TextInputPanelState(); +} + +class _TextInputPanelState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final value = _controller.text.trim(); + Navigator.pop(context, value); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + return Material( + color: colors.surface, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: screenWidth * 0.75, + height: double.infinity, + child: Column( + children: [ + // Header + Container( + padding: EdgeInsets.fromLTRB(16, MediaQuery.of(context).padding.top + 12, 8, 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row( + children: [ + Text(widget.title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + TextButton( + onPressed: _submit, + child: Text('完成', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)), + ), + ], + ), + ), + // 输入框 + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _controller, + autofocus: true, + keyboardType: widget.keyboardType, + style: TextStyle(fontSize: 15, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + onPressed: () => _controller.clear(), + ) + : null, + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7299b5c..36af2d7 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 886932b..eeaf357 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + dynamic_color file_selector_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 609ed59..4d90895 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import dynamic_color import file_picker import file_selector_macos import flutter_inappwebview_macos @@ -18,6 +19,7 @@ import wakelock_plus import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 91b678b..269ae23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" equatable: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2096ae3..a343888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: http: ^1.2.0 url_launcher: ^6.2.5 webview_flutter: ^4.8.0 + dynamic_color: ^1.8.1 package_info_plus: ^8.0.0 extended_text_field: ^16.0.2 uuid: ^4.5.0 @@ -56,7 +57,7 @@ flutter_launcher_icons: flutter: uses-material-design: true - + assets: - assets/images/ - assets/icon/ @@ -69,3 +70,17 @@ flutter: - assets/foliate-js/src/vendor/prism/ - assets/foliate-js/src/vendor/prism/components/ - assets/foliate-js/src/vendor/prism/themes/ + + fonts: + - family: LXGWWenKai + fonts: + - asset: assets/fonts/LXGWWenKai-Regular.ttf + - family: OPPOSans + fonts: + - asset: assets/fonts/OPPO_Sans4.0.ttf + - family: NotoSerifSC + fonts: + - asset: assets/fonts/NotoSerifSC-Regular.ttf + - family: SmileySans + fonts: + - asset: assets/fonts/SmileySans-Oblique.ttf diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 25d583a..ab82016 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + DynamicColorPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 75d055e..ce48080 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + dynamic_color file_selector_windows flutter_inappwebview_windows permission_handler_windows