代码优化,功能新增

This commit is contained in:
DelLevin-Home
2026-06-26 23:46:37 +08:00
parent f7ef50a677
commit 45137b96d6
59 changed files with 5041 additions and 4937 deletions

View File

@@ -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<MyApp> with WidgetsBindingObserver {
],
child: Consumer<AppProvider>(
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<MyApp> with WidgetsBindingObserver {
return _AppIconWrapper(iconName: iconName, child: child!);
},
);
},
);
},
),
);

View File

@@ -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<String, dynamic> 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,
);
}

View File

@@ -31,11 +31,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0;
final ValueNotifier<bool> _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<BookDetailPage> {
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<BookDetailPage> {
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<BookDetailPage> {
SafeArea(
child: Column(
children: [
// 顶部栏
// 顶部栏:只在滚动后显示标题
SizedBox(
height: 48,
child: Row(children: [
@@ -151,9 +155,19 @@ class _BookDetailPageState extends State<BookDetailPage> {
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<bool>(
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<BookDetailPage> {
// 可滚动内容
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<BookDetailPage> {
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),
]),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,6 @@ class _BookTabPageState extends State<BookTabPage> {
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<BookTabPage> {
final provider = context.read<AppProvider>();
_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<BookTabPage> {
}
}
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<BookTabPage> {
_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<BookTabPage> {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
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; });
}

View File

@@ -296,6 +296,24 @@ class _MainContentPageState extends State<MainContentPage> {
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<AppProvider>().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<AppProvider>().loadBooks(); })
: tab.label == '笔记'
? () => _showSortMenu(context, '笔记排序', UserPrefs().noteSortMode, [
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
], (v) { UserPrefs().setNoteSortMode(v); context.read<AppProvider>().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<MainContentPage> {
);
}
// ─── PageView 内容区 ─────────────────────────────────
void _showSortMenu(BuildContext context, String title, int current, List<(int, String, IconData)> options, ValueChanged<int> 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<int> 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<AppProvider>(
@@ -359,7 +415,11 @@ class _MainContentPageState extends State<MainContentPage> {
}
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(

View File

@@ -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<MediaCalendarPage> createState() => _MediaCalendarPageState();
}
class _MediaCalendarPageState extends State<MediaCalendarPage> {
late DateTime _currentMonth;
DateTime? _selectedDay;
// {DateTime(dayOnly): [{path, title, type, data}]}
late Map<DateTime, List<_CalendarItem>> _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<AppProvider>();
final map = <DateTime, List<_CalendarItem>>{};
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,
});
}

View File

@@ -35,6 +35,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final ValueNotifier<bool> _draggingPoster = ValueNotifier(false);
final GlobalKey _posterImageKey = GlobalKey();
double _posterImageHeight = 0.0;
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
@override
void initState() {
@@ -48,6 +50,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
void dispose() {
_posterOffset.dispose();
_draggingPoster.dispose();
_showTitle.dispose();
_overlayScrollController?.dispose();
super.dispose();
}
@@ -75,25 +79,20 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
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<MovieDetailPage> {
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<MovieDetailPage> {
// 内容
SafeArea(
child: Column(children: [
// 顶部栏:只在滚动后显示标题
SizedBox(
height: 48,
child: Row(children: [
@@ -188,15 +194,26 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
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<bool>(
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<MovieDetailPage> {
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),
]),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -126,31 +126,34 @@ class _MovieSharePageState extends State<MovieSharePage> {
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),
]),
),
);

View File

@@ -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<MovieTabPage> {
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<MovieTabPage> {
final provider = context.read<AppProvider>();
_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<MovieTabPage> {
}
}
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<MovieTabPage> {
_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<MovieTabPage> {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
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);

View File

@@ -30,6 +30,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
titleSpacing: 0,
title: Text(
note.title.isNotEmpty
? note.title

View File

@@ -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<NoteFormPage> {
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<NoteFormPage> {
_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<void> _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<AppProvider>().updateNote(updatedNote);
} else if (_savedNote != null) {
final updatedNote = _savedNote!.copyWith(
title: title,
content: content,
tags: _tags,
images: _images,
updatedAt: now,
);
await context.read<AppProvider>().updateNote(updatedNote);
_savedNote = updatedNote;
} else {
final noteId = now.millisecondsSinceEpoch.toString();
List<String> 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<AppProvider>().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<NoteFormPage> {
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<NoteFormPage> {
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<NoteFormPage> {
);
}
/// 显示添加标签对话框
Future<void> _showAddTagDialog() async {
final controller = TextEditingController();
// 从 tags 表获取已有标签
/// 显示标签侧边面板
Future<void> _showTagPanel() async {
final provider = context.read<AppProvider>();
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<NoteFormPage> {
return false;
}
/// 离开确认
/// 离开确认(自动保存后直接返回)
Future<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
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<void> _saveNote() async {
_autoSaveTimer?.cancel();
final content = _contentController.text.trim();
final title = _titleController.text.trim();

View File

@@ -24,10 +24,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
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<NoteTabPage> {
final provider = context.read<AppProvider>();
_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<NoteTabPage> {
if (!_initialized || !mounted) return;
final provider = context.read<AppProvider>();
// 检查回到顶部信号
if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) {
_lastScrollSignal = provider.scrollToTopSignal;
if (_scrollController.hasClients) {
@@ -63,15 +57,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
}
}
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<NoteTabPage> {
Future<void> _loadFirst() async {
_initialized = true;
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
List<Note> list = await context.read<AppProvider>().loadNotesPaged(offset: 0);
if (_selectedTag != null) {
final all = context.read<AppProvider>().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<AppProvider>().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<NoteTabPage> {
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
List<Note> list;
if (_selectedTag != null) {
final all = context.read<AppProvider>().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<AppProvider>().loadNotesPaged(offset: _offset);
}
final sortMode = UserPrefs().noteSortMode;
final list = await context.read<AppProvider>().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<NoteTabPage> {
final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(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<NoteTabPage> {
return _buildListView();
}
Widget _buildTagBar(AppProvider provider) {
final colors = Theme.of(context).colorScheme;
final allTags = <String>{};
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<NoteTabPage> {
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<NoteTabPage> {
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<NoteTabPage> {
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<NoteTabPage> {
]),
),
);
}
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<NoteTabPage> {
);
}
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<AppProvider>().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<AppProvider>().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<AppProvider>().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) {

View File

@@ -158,7 +158,9 @@ class _OnlineSearchPageState extends State<OnlineSearchPage> {
}
} 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<OnlineSearchPage> {
}
} 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<OnlineSearchPage> {
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: [

View File

@@ -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<PersonListPage> createState() => _PersonListPageState();
}
class _PersonListPageState extends State<PersonListPage> {
String _filter = 'all'; // all / 导演 / 编剧 / 主演 / 作者
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<_PersonEntry> _buildPersons() {
final provider = context.read<AppProvider>();
final map = <String, _PersonEntry>{};
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 = <String>[];
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<String> roles = {};
final List<Movie> movies = [];
final List<Book> books = [];
_PersonEntry({required this.name});
}
class _WorkItem {
final String title;
final List<String> roles;
final String? path;
final dynamic data;
_WorkItem({required this.title, required this.roles, this.path, required this.data});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<StrollPage> {
final _random = Random();
final List<_StrollItem> _items = [];
final Set<String> _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<StrollPage> {
super.dispose();
}
// ─── 数据加载 ───
void _loadBatch(int count) {
final provider = context.read<AppProvider>();
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 = <String, List<dynamic>>{};
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 = <List<_StrollItem>>[];
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 = <String>[];
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 = <String>[];
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<StrollPage> {
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<AppProvider>();
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<StrollPage> {
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<StrollPage> {
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<String> 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,
});
}

View File

@@ -100,11 +100,6 @@ class _TagManagementPageState extends State<TagManagementPage> {
@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<TagManagementPage> {
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<TagManagementPage> {
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<TagManagementPage> {
}
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<TagManagementPage> {
);
}
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<String, dynamic> tag) {
final colors = Theme.of(context).colorScheme;
final name = tag['name'] as String;
@@ -480,6 +490,10 @@ class _TagManagementPageState extends State<TagManagementPage> {
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<TagManagementPage> {
);
}
void _showTagItems(String tagName) {
final provider = context.read<AppProvider>();
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() {

View File

@@ -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<void> loadNotes() async {
_notes = await _noteDao.getAllNotes();
Future<void> 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<List<Movie>> loadMoviesPaged({String? status, required int offset}) async {
return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset);
Future<List<Movie>> loadMoviesPaged({String? status, required int offset, int sortMode = 0}) async {
return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode);
}
Future<List<Book>> loadBooksPaged({String? status, required int offset}) async {
return _bookDao.getBooksPaged(status: status, limit: _pageSize, offset: offset);
Future<List<Book>> loadBooksPaged({String? status, required int offset, int sortMode = 0}) async {
return _bookDao.getBooksPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode);
}
Future<List<Note>> loadNotesPaged({required int offset}) async {
return _noteDao.getNotesPaged(limit: _pageSize, offset: offset);
Future<List<Note>> 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<Movie> get movies => _movies;
List<Book> get books => _books;
List<Note> 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<void> toggleNotePin(String id, bool isPinned) async {
await _noteDao.togglePin(id, isPinned);
await loadNotes();
}
// ========== 影评相关方法 ==========

View File

@@ -28,7 +28,7 @@ class BookDao {
});
// 分页查询书籍记录
Future<List<Book>> getBooksPaged({String? status, int limit = 20, int offset = 0}) => _wrap('getBooksPaged', () async {
Future<List<Book>> 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<dynamic> 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<List<Book>> getBooksByStatus(String status) => _wrap('getBooksByStatus', () async {
final db = await _dbHelper.database;

View File

@@ -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<void> _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<void> _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
)
''');

View File

@@ -28,7 +28,7 @@ class MovieDao {
});
// 分页查询影视记录
Future<List<Movie>> getMoviesPaged({String? status, int limit = 20, int offset = 0}) => _wrap('getMoviesPaged', () async {
Future<List<Movie>> 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<dynamic> 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<List<Movie>> getMoviesByStatus(String status) => _wrap('getMoviesByStatus', () async {
final db = await _dbHelper.database;

View File

@@ -16,25 +16,34 @@ class NoteDao {
}
// 获取所有未删除的笔记
Future<List<Note>> getAllNotes() => _wrap('getAllNotes', () async {
Future<List<Note>> getAllNotes({int sortMode = 0}) => _wrap('getAllNotes', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> 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<List<Note>> getNotesPaged({int limit = 20, int offset = 0}) => _wrap('getNotesPaged', () async {
Future<List<Note>> 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<Note?> getNoteById(String id) => _wrap('getNoteById', () async {
final db = await _dbHelper.database;
@@ -75,6 +84,17 @@ class NoteDao {
);
});
// 切换笔记置顶状态
Future<int> 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],
);
});
// ========== 回收站相关方法 ==========
// 获取已删除的笔记

View File

@@ -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<void> _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 [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<String> 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,

View File

@@ -51,6 +51,10 @@ class UserPrefs {
int get colorSchemeIndex => prefs.getInt('colorSchemeIndex') ?? 0;
Future<bool> setColorSchemeIndex(int value) => prefs.setInt('colorSchemeIndex', value);
/// 字体: 空字符串=系统默认
String get fontFamily => prefs.getString('fontFamily') ?? '';
Future<bool> setFontFamily(String value) => prefs.setString('fontFamily', value);
/// 上映日期显示到日true/ 显示到月false
bool get showExactReleaseDate => prefs.getBool('showExactReleaseDate') ?? true;
Future<bool> setShowExactReleaseDate(bool value) => prefs.setBool('showExactReleaseDate', value);
@@ -101,6 +105,18 @@ class UserPrefs {
int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0;
Future<bool> setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value);
/// 笔记排序方式 (0: 更新时间, 1: 创建时间)
int get noteSortMode => prefs.getInt('noteSortMode') ?? 0;
Future<bool> setNoteSortMode(int value) => prefs.setInt('noteSortMode', value);
/// 影视排序方式 (0: 更新时间, 1: 创建时间, 2: 评分)
int get movieSortMode => prefs.getInt('movieSortMode') ?? 0;
Future<bool> setMovieSortMode(int value) => prefs.setInt('movieSortMode', value);
/// 书籍排序方式 (0: 更新时间, 1: 创建时间, 2: 评分)
int get bookSortMode => prefs.getInt('bookSortMode') ?? 0;
Future<bool> setBookSortMode(int value) => prefs.setInt('bookSortMode', value);
/// 影视布局样式 (0: 海报网格, 1: 列表)
int get movieLayoutStyle => prefs.getInt('movieLayoutStyle') ?? 0;
Future<bool> setMovieLayoutStyle(int value) => prefs.setInt('movieLayoutStyle', value);

View File

@@ -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<AnimatedStarRating> createState() => _AnimatedStarRatingState();
}
class _AnimatedStarRatingState extends State<AnimatedStarRating>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _animations;
late Animation<double> _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<double>(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<AnimatedStarRating>
} 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),
),
),
],
],

View File

@@ -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<CustomDrawer> {
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<CustomDrawer> {
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<CustomDrawer> {
],
),
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<CustomDrawer> {
)),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,

View File

@@ -1,18 +1,55 @@
import 'package:flutter/material.dart';
/// 类型/标签选择全屏页(影视类型、书籍类型通用)
/// 类型/标签选择右侧弹窗(影视类型、导演、编剧、主演、书籍类型通用)
class GenreSelectorPage extends StatefulWidget {
final String title;
final List<String> existingTags;
final List<String>? existingTags;
final Future<List<String>>? existingTagsFuture;
final List<String> 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<List<String>?> show({
required BuildContext context,
required String title,
List<String>? existingTags,
Future<List<String>>? existingTagsFuture,
required List<String> initialSelected,
String hint = '',
}) {
return showGeneralDialog<List<String>>(
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<GenreSelectorPage> createState() => _GenreSelectorPageState();
@@ -21,12 +58,24 @@ class GenreSelectorPage extends StatefulWidget {
class _GenreSelectorPageState extends State<GenreSelectorPage> {
late List<String> _selected;
final _controller = TextEditingController();
String _newTag = '';
String _query = '';
List<String>? _loadedTags;
bool _loading = true;
@override
void initState() {
super.initState();
_selected = List<String>.from(widget.initialSelected);
_loadTags();
}
Future<void> _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<GenreSelectorPage> {
}
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<GenreSelectorPage> {
@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(),
),
),
),
],
],

View File

@@ -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<AppProvider>();
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<AppProvider>().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<AppProvider>().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<AppProvider>().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) {

View File

@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
/// 标签侧边面板 - 从右侧滑入,用于选择和新建标签
class TagSidePanel extends StatefulWidget {
final List<String> selectedTags;
final List<String> allAvailableTags;
final ValueChanged<List<String>> onTagsChanged;
const TagSidePanel({
super.key,
required this.selectedTags,
required this.allAvailableTags,
required this.onTagsChanged,
});
static Future<void> show({
required BuildContext context,
required List<String> selectedTags,
required List<String> allAvailableTags,
required ValueChanged<List<String>> 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<TagSidePanel> createState() => _TagSidePanelState();
}
class _TagSidePanelState extends State<TagSidePanel> {
late List<String> _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(),
),
),
),
],
),
),
);
}
}

View File

@@ -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<String?> show({
required BuildContext context,
required String title,
String initialValue = '',
String hint = '',
TextInputType keyboardType = TextInputType.text,
}) {
return showGeneralDialog<String>(
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<TextInputPanel> createState() => _TextInputPanelState();
}
class _TextInputPanelState extends State<TextInputPanel> {
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(),
),
),
],
),
),
);
}
}