From 0af9d4368a4eae58a2501969d4224cac8d77266e Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Fri, 19 Jun 2026 20:02:51 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B6=E9=83=A8tab=E6=A0=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- lib/pages/tag_management_page.dart | 607 +++++++++++++++++++---------- lib/widgets/book_status_bar.dart | 86 ++-- lib/widgets/movie_status_bar.dart | 86 ++-- 4 files changed, 502 insertions(+), 281 deletions(-) diff --git a/README.md b/README.md index 46f8dad..1a3e5ae 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ lib/ ## 数据存储 -- **数据库**:`/mooknote/mooknote.db` -- **图片**:`/mooknote/images/<类别>/<条目ID>/<文件名>` +- **数据存储位置**:`/mooknote/mooknote.db` +- **图片存储位置**:`/mooknote/images/<类别>/<条目ID>/<文件名>` - 类别:`movie` / `book` / `note` ## 环境要求 diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart index 50b0761..27a5b55 100644 --- a/lib/pages/tag_management_page.dart +++ b/lib/pages/tag_management_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; @@ -19,6 +20,8 @@ class _TagManagementPageState extends State { static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined]; final Map>> _tagCache = {}; + Map _usageCounts = {}; + String? _newlyAddedTagId; @override void initState() { @@ -26,6 +29,35 @@ class _TagManagementPageState extends State { _loadTags(_tabTypes[0]); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateUsageCounts(); + } + + void _updateUsageCounts() { + final provider = context.read(); + final counts = {}; + + for (final m in provider.movies.where((m) => !m.isDeleted)) { + for (final g in m.genres) { + counts[g] = (counts[g] ?? 0) + 1; + } + } + for (final b in provider.books.where((b) => !b.isDeleted)) { + for (final g in b.genres) { + counts[g] = (counts[g] ?? 0) + 1; + } + } + for (final n in provider.notes.where((n) => !n.isDeleted)) { + for (final t in n.tags) { + counts[t] = (counts[t] ?? 0) + 1; + } + } + + _usageCounts = counts; + } + Future _loadTags(String type) async { final provider = context.read(); final tags = await provider.getTags(type); @@ -40,44 +72,26 @@ class _TagManagementPageState extends State { if (mounted) { ToastUtil.show(context, count > 0 ? '已同步 $count 个新标签' : '标签已是最新'); await _loadTags(_currentType); + _updateUsageCounts(); } } finally { if (mounted) setState(() => _isSyncing = false); } } + void _onTabChanged(int index) { + setState(() => _currentIndex = index); + _loadTags(_tabTypes[index]); + } + String get _currentType => _tabTypes[_currentIndex]; - Map _getTagUsageCounts(String type) { - final provider = context.read(); - final counts = {}; - - switch (type) { - case 'movie_genre': - for (final m in provider.movies.where((m) => !m.isDeleted)) { - for (final g in m.genres) { - counts[g] = (counts[g] ?? 0) + 1; - } - } - break; - case 'book_genre': - for (final b in provider.books) { - for (final g in b.genres) { - counts[g] = (counts[g] ?? 0) + 1; - } - } - break; - case 'note_tag': - for (final n in provider.notes.where((n) => !n.isDeleted)) { - for (final t in n.tags) { - counts[t] = (counts[t] ?? 0) + 1; - } - } - break; - } - return counts; + int get _activeTagCount { + return (_tagCache[_currentType] ?? []).where((t) => (_usageCounts[t['name']] ?? 0) > 0).length; } + // ─── build ───────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -85,6 +99,16 @@ class _TagManagementPageState extends State { backgroundColor: colors.surfaceContainerHigh, appBar: AppBar( title: const Text('标签管理'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(20), + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + '共 ${(_tagCache[_currentType] ?? []).length} 个标签 · 已使用 $_activeTagCount 个', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ), + ), actions: [ _isSyncing ? Padding( @@ -104,7 +128,14 @@ class _TagManagementPageState extends State { const SizedBox(height: 12), _buildTabSelector(), const SizedBox(height: 16), - Expanded(child: _buildTagList(_currentType)), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _buildTagList(_currentType), + ), + ), ], ), floatingActionButton: GestureDetector( @@ -132,6 +163,8 @@ class _TagManagementPageState extends State { ); } + // ─── Tab 选择器 ───────────────────────────────────────────────────────── + Widget _buildTabSelector() { final colors = Theme.of(context).colorScheme; return Container( @@ -149,8 +182,8 @@ class _TagManagementPageState extends State { child: Stack( children: [ AnimatedPositioned( - duration: const Duration(milliseconds: 600), - curve: Curves.elasticOut, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, left: _currentIndex * tabWidth, top: 0, bottom: 0, width: tabWidth, child: Padding( @@ -173,16 +206,18 @@ class _TagManagementPageState extends State { return Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () { - setState(() => _currentIndex = i); - _loadTags(_tabTypes[i]); - }, - child: Container( - height: double.infinity, - alignment: Alignment.center, - child: Text(_typeLabels[i], - style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: selected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4))), + onTap: () => _onTabChanged(i), + child: AnimatedOpacity( + opacity: selected ? 1.0 : 0.4, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Container( + height: double.infinity, + alignment: Alignment.center, + child: Text(_typeLabels[i], + style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: colors.onSurface)), + ), ), ), ); @@ -196,10 +231,10 @@ class _TagManagementPageState extends State { ); } + // ─── 标签列表 ────────────────────────────────────────────────────────── + Widget _buildTagList(String type) { - final colors = Theme.of(context).colorScheme; final tags = _tagCache[type] ?? []; - final usageCounts = _getTagUsageCounts(type); if (tags.isEmpty) { return _buildEmptyState(type); @@ -207,83 +242,72 @@ class _TagManagementPageState extends State { final sorted = List>.from(tags) ..sort((a, b) { - final ca = usageCounts[a['name']] ?? 0; - final cb = usageCounts[b['name']] ?? 0; + final ca = _usageCounts[a['name']] ?? 0; + final cb = _usageCounts[b['name']] ?? 0; return cb.compareTo(ca); }); return Padding( + key: ValueKey(type), padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 80), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: sorted.map((tag) => _buildTagChip(tag)).toList(), + ), + ), + ); + } + + Widget _buildTagChip(Map tag) { + final colors = Theme.of(context).colorScheme; + final name = tag['name'] as String; + final count = _usageCounts[name] ?? 0; + final tagId = tag['id'] as String; + final isNew = tagId == _newlyAddedTagId; + + return GestureDetector( + key: ValueKey(tagId), + onTap: () => _showTagMenu(tag), + onLongPress: () => _showTagMenu(tag), + child: isNew + ? _NewTagHighlight( + child: _tagChipContent(name, count, colors), + ) + : _tagChipContent(name, count, colors), + ); + } + + Widget _tagChipContent(String name, int count, ColorScheme colors) { + return Container( + padding: const EdgeInsets.only(left: 14, right: 10, top: 8, bottom: 8), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2)), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Text('共 ${tags.length} 个标签', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), - const Spacer(), - Text('已使用 ${usageCounts.length} 个', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.2))), - ], + Text(name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), + if (count > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(8)), + child: Text('$count', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))), ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 80), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: sorted.map((tag) => _buildTagChip(tag, usageCounts)).toList(), - ), - ), - ), + ], ], ), ); } - Widget _buildTagChip(Map tag, Map usageCounts) { - final colors = Theme.of(context).colorScheme; - final name = tag['name'] as String; - final count = usageCounts[name] ?? 0; - - return GestureDetector( - onLongPress: () => _showRenameDialog(tag), - child: Container( - padding: const EdgeInsets.only(left: 14, right: 6, top: 8, bottom: 8), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2)), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), - if (count > 0) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(8)), - child: Text('$count', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))), - ), - ], - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showDeleteDialog(tag), - child: Container( - width: 22, height: 22, - decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(11)), - child: Icon(Icons.close, size: 12, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - ], - ), - ), - ); - } + // ─── 新标签高亮动画 ───────────────────────────────────────────────────── Widget _buildEmptyState(String type) { final colors = Theme.of(context).colorScheme; @@ -293,6 +317,7 @@ class _TagManagementPageState extends State { final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签']; return Center( + key: ValueKey('empty_$type'), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -311,6 +336,73 @@ class _TagManagementPageState extends State { ); } + // ─── 标签操作菜单 ─────────────────────────────────────────────────────── + + void _showTagMenu(Map tag) { + final colors = Theme.of(context).colorScheme; + final name = tag['name'] as String; + + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center(child: Container( + width: 40, height: 4, margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), + )), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + const SizedBox(height: 16), + _menuAction(Icons.edit_outlined, '重命名', colors, () { + Navigator.pop(ctx); + _showRenameDialog(tag); + }), + _menuAction(Icons.delete_outline, '删除', colors, () { + Navigator.pop(ctx); + _showDeleteDialog(tag); + }, isDestructive: true), + ], + ), + ), + ); + }, + ); + } + + Widget _menuAction(IconData icon, String title, ColorScheme colors, VoidCallback onTap, {bool isDestructive = false}) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface.withValues(alpha: 0.7)), + const SizedBox(width: 14), + Text(title, style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isDestructive ? const Color(0xFFE53935) : colors.onSurface, + )), + ], + ), + ), + ); + } + + // ─── 添加标签 ────────────────────────────────────────────────────────── + void _showAddDialog() { final colors = Theme.of(context).colorScheme; final controller = TextEditingController(); @@ -360,18 +452,25 @@ class _TagManagementPageState extends State { Future _doAddTag(BuildContext ctx, String name, String type) async { if (name.isEmpty) return; try { - await context.read().addTag(name, type); + final provider = context.read(); + final newId = await provider.addTag(name, type); + if (!mounted) return; + if (ctx.mounted) { + Navigator.pop(ctx); + ToastUtil.show(context, '添加成功'); + } + setState(() => _newlyAddedTagId = newId); + Timer(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _newlyAddedTagId = null); + }); + await _loadTags(type); } catch (e) { if (ctx.mounted) ToastUtil.show(ctx, '添加失败:该标签已存在'); - return; } - if (ctx.mounted) { - Navigator.pop(ctx); - ToastUtil.show(context, '添加成功'); - } - await _loadTags(type); } + // ─── 重命名 ──────────────────────────────────────────────────────────── + void _showRenameDialog(Map tag) { final colors = Theme.of(context).colorScheme; final controller = TextEditingController(text: tag['name'] as String); @@ -432,13 +531,15 @@ class _TagManagementPageState extends State { if (success) await _loadTags(type); } + // ─── 删除标签(简化版:默认仅删除标签,高级选项可展开) ───────────────────── + void _showDeleteDialog(Map tag) { - final colors = Theme.of(context).colorScheme; final tagId = tag['id'] as String; final type = tag['type'] as String; final name = tag['name'] as String; - String? selectedAction = 'remove'; - final replacementController = TextEditingController(); + String? selectedAction = 'deleteOnly'; + String? selectedReplacement; + bool showAdvanced = false; final otherTags = (_tagCache[type] ?? []) .where((t) => t['id'] != tagId) @@ -448,112 +549,144 @@ class _TagManagementPageState extends State { showDialog( context: context, builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) => AlertDialog( - backgroundColor: colors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), - child: Text(name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), - ), - const SizedBox(width: 10), - Text('删除标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - ], - ), - titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), - content: SizedBox( - width: double.maxFinite, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.of(ctx).size.height * 0.45), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text('删除后对已有条目的影响:', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), - const SizedBox(height: 12), - _buildDeleteOption(value: 'remove', groupValue: selectedAction, onChanged: (v) => setDialogState(() => selectedAction = v), title: '从所有条目中移除该标签', subtitle: '标签将从影视/书籍/笔记中清除'), - const SizedBox(height: 4), - _buildDeleteOption(value: 'deleteOnly', groupValue: selectedAction, onChanged: (v) => setDialogState(() => selectedAction = v), title: '仅删除标签', subtitle: '保留已有条目上的标签名'), - const SizedBox(height: 4), - _buildDeleteOption(value: 'replace', groupValue: selectedAction, onChanged: (v) => setDialogState(() => selectedAction = v), title: '替换为其他标签'), - if (selectedAction == 'replace') - Padding( - padding: const EdgeInsets.only(left: 40, top: 10), - child: otherTags.isNotEmpty - ? Wrap( - spacing: 8, runSpacing: 8, - children: otherTags.map((t) { - final isSelected = replacementController.text == t; - return GestureDetector( - onTap: () { - replacementController.text = isSelected ? '' : t; - setDialogState(() {}); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: isSelected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: isSelected ? colors.primary : colors.outlineVariant, width: 0.5), - ), - child: Text(t, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.7))), - ), - ); - }).toList(), - ) - : Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), - child: Text('无其他标签可替换', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), - ), - ), - ], + builder: (ctx, setDialogState) { + final bc = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: bc.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: bc.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Text(name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: bc.onSurface.withValues(alpha: 0.6))), ), - ), + const SizedBox(width: 10), + Text('删除标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: bc.onSurface)), + ], ), - ), - contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), - Container( - decoration: BoxDecoration(color: const Color(0xFFE53935), borderRadius: BorderRadius.circular(20)), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - String? replacement; - if (selectedAction == 'replace') { - replacement = replacementController.text.trim().isNotEmpty ? replacementController.text.trim() : null; - if (replacement == null) return; - } - Navigator.pop(ctx, {'action': selectedAction, 'replacement': replacement}); - }, - borderRadius: BorderRadius.circular(20), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Text('删除', style: TextStyle(fontSize: 14, color: Colors.white, fontWeight: FontWeight.w500)), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(ctx).size.height * 0.45), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + // 默认选项 + _buildDeleteOption( + value: 'deleteOnly', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '仅删除标签', + subtitle: '保留已有条目上的标签名,不影响数据', + colors: bc, + ), + const SizedBox(height: 8), + // 展开/收起高级选项 + GestureDetector( + onTap: () => setDialogState(() => showAdvanced = !showAdvanced), + child: Row( + children: [ + Text('更多选项', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: bc.primary)), + Icon(showAdvanced ? Icons.expand_less : Icons.expand_more, size: 16, color: bc.primary), + ], + ), + ), + if (showAdvanced) ...[ + const SizedBox(height: 10), + _buildDeleteOption( + value: 'remove', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '从所有条目中移除', + subtitle: '彻底清除该标签在所有条目中的记录', + colors: bc, + ), + const SizedBox(height: 4), + _buildDeleteOption( + value: 'replace', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '替换为其他标签', + subtitle: '选择一个已有标签替代', + colors: bc, + ), + if (selectedAction == 'replace') + Padding( + padding: const EdgeInsets.only(left: 40, top: 10), + child: otherTags.isNotEmpty + ? Wrap( + spacing: 8, runSpacing: 8, + children: otherTags.map((t) { + final isSelected = selectedReplacement == t; + return GestureDetector( + onTap: () => setDialogState(() => selectedReplacement = isSelected ? null : t), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? bc.primary : bc.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isSelected ? bc.primary : bc.outlineVariant, width: 0.5), + ), + child: Text(t, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isSelected ? bc.onPrimary : bc.onSurface.withValues(alpha: 0.7))), + ), + ); + }).toList(), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration(color: bc.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Text('无其他标签可替换', style: TextStyle(fontSize: 13, color: bc.onSurface.withValues(alpha: 0.35))), + ), + ), + ], + ], ), ), ), ), - ], - actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - ), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: bc.onSurface.withValues(alpha: 0.4)))), + Container( + decoration: BoxDecoration(color: const Color(0xFFE53935), borderRadius: BorderRadius.circular(20)), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (selectedAction == 'replace' && (selectedReplacement == null || selectedReplacement!.isEmpty)) return; + Navigator.pop(ctx, {'action': selectedAction, 'replacement': selectedReplacement}); + }, + borderRadius: BorderRadius.circular(20), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text('删除', style: TextStyle(fontSize: 14, color: Colors.white, fontWeight: FontWeight.w500)), + ), + ), + ), + ), + ], + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + ); + }, ), ).then((result) async { if (result == null) return; final action = result['action'] as String; final replacement = result['replacement'] as String?; + if (!mounted) return; + final provider = context.read(); if (action == 'deleteOnly') { - await context.read().deleteTagOnly(tagId, type); + await provider.deleteTagOnly(tagId, type); } else { - await context.read().deleteTag(tagId, type, replacementName: replacement); + await provider.deleteTag(tagId, type, replacementName: replacement); } - if (mounted) ToastUtil.show(context, '删除成功'); + if (!mounted) return; + ToastUtil.show(context, '删除成功'); await _loadTags(type); }); } @@ -564,8 +697,8 @@ class _TagManagementPageState extends State { required ValueChanged onChanged, required String title, String? subtitle, + required ColorScheme colors, }) { - final colors = Theme.of(context).colorScheme; final selected = value == groupValue; return GestureDetector( onTap: () => onChanged(value), @@ -598,3 +731,55 @@ class _TagManagementPageState extends State { ); } } + +// ─── 新标签高亮动画 Widget ───────────────────────────────────────────────── + +class _NewTagHighlight extends StatefulWidget { + final Widget child; + const _NewTagHighlight({required this.child}); + + @override + State<_NewTagHighlight> createState() => _NewTagHighlightState(); +} + +class _NewTagHighlightState extends State<_NewTagHighlight> with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final opacity = _controller.value < 0.3 + ? (_controller.value / 0.3).clamp(0.0, 1.0) + : (1.0 - (_controller.value - 0.3) / 0.7).clamp(0.0, 1.0); + final scale = 1.0 + 0.06 * (1.0 - _controller.value); + return Transform.scale( + scale: scale, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colors.primary.withValues(alpha: 0.12 * opacity), + border: Border.all(color: colors.primary.withValues(alpha: 0.3 * opacity), width: 1), + ), + child: widget.child, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/book_status_bar.dart b/lib/widgets/book_status_bar.dart index 93b7f1c..ff2e882 100644 --- a/lib/widgets/book_status_bar.dart +++ b/lib/widgets/book_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 阅读状态选择栏 - 交叉渐变动画 +/// 阅读状态选择栏 - 平滑过渡动画 class BookStatusBar extends StatelessWidget { const BookStatusBar({super.key}); @@ -11,6 +11,7 @@ class BookStatusBar extends StatelessWidget { final colors = Theme.of(context).colorScheme; return Consumer( builder: (context, provider, child) { + final currentIndex = provider.bookStatusIndex; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -23,13 +24,45 @@ class BookStatusBar extends StatelessWidget { color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(24), ), - child: SizedBox( - height: 40, - child: Row(children: [ - _buildTab(colors, '已读', Icons.check_circle_outline, provider.bookStatusIndex == 0, () => provider.setBookStatusIndex(0)), - _buildTab(colors, '在读', Icons.menu_book_outlined, provider.bookStatusIndex == 1, () => provider.setBookStatusIndex(1)), - _buildTab(colors, '想读', Icons.bookmark_outlined, provider.bookStatusIndex == 2, () => provider.setBookStatusIndex(2)), - ]), + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 3; + return SizedBox( + height: 40, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: currentIndex * tabWidth, + top: 0, bottom: 0, width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + ), + ), + ), + Row( + children: [ + _buildTab(colors, '已读', Icons.check_circle_outline, currentIndex == 0, + () => provider.setBookStatusIndex(0)), + _buildTab(colors, '在读', Icons.menu_book_outlined, currentIndex == 1, + () => provider.setBookStatusIndex(1)), + _buildTab(colors, '想读', Icons.bookmark_outlined, currentIndex == 2, + () => provider.setBookStatusIndex(2)), + ], + ), + ], + ), + ); + }, ), ), ); @@ -42,38 +75,23 @@ class BookStatusBar extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 400), + child: AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.5, + duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, - margin: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isSelected ? colors.primary : Colors.transparent, - borderRadius: BorderRadius.circular(20), - boxShadow: isSelected ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2))] : null, - ), - alignment: Alignment.center, - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6), - ), + child: SizedBox.expand( child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: Icon(icon, key: ValueKey(isSelected), size: 16, - color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6)), - ), + Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), const SizedBox(width: 6), - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: Text(label, key: ValueKey('$label$isSelected')), - ), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface, + )), ], ), ), diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart index ec36434..2bdddde 100644 --- a/lib/widgets/movie_status_bar.dart +++ b/lib/widgets/movie_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 观影状态选择栏 - 交叉渐变动画 +/// 观影状态选择栏 - 平滑过渡动画 class MovieStatusBar extends StatelessWidget { const MovieStatusBar({super.key}); @@ -11,6 +11,7 @@ class MovieStatusBar extends StatelessWidget { final colors = Theme.of(context).colorScheme; return Consumer( builder: (context, provider, child) { + final currentIndex = provider.movieStatusIndex; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -23,13 +24,45 @@ class MovieStatusBar extends StatelessWidget { color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(24), ), - child: SizedBox( - height: 40, - child: Row(children: [ - _buildTab(colors, '已看', Icons.check_circle_outline, provider.movieStatusIndex == 0, () => provider.setMovieStatusIndex(0)), - _buildTab(colors, '在看', Icons.play_circle_outline, provider.movieStatusIndex == 1, () => provider.setMovieStatusIndex(1)), - _buildTab(colors, '想看', Icons.bookmark_outline, provider.movieStatusIndex == 2, () => provider.setMovieStatusIndex(2)), - ]), + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 3; + return SizedBox( + height: 40, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: currentIndex * tabWidth, + top: 0, bottom: 0, width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + ), + ), + ), + Row( + children: [ + _buildTab(colors, '已看', Icons.check_circle_outline, currentIndex == 0, + () => provider.setMovieStatusIndex(0)), + _buildTab(colors, '在看', Icons.play_circle_outline, currentIndex == 1, + () => provider.setMovieStatusIndex(1)), + _buildTab(colors, '想看', Icons.bookmark_outline, currentIndex == 2, + () => provider.setMovieStatusIndex(2)), + ], + ), + ], + ), + ); + }, ), ), ); @@ -42,38 +75,23 @@ class MovieStatusBar extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 400), + child: AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.5, + duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, - margin: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isSelected ? colors.primary : Colors.transparent, - borderRadius: BorderRadius.circular(20), - boxShadow: isSelected ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2))] : null, - ), - alignment: Alignment.center, - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6), - ), + child: SizedBox.expand( child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: Icon(icon, key: ValueKey(isSelected), size: 16, - color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6)), - ), + Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), const SizedBox(width: 6), - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: Text(label, key: ValueKey('$label$isSelected')), - ), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface, + )), ], ), ),