generated from dellevin/template
代码优化,功能新增
This commit is contained in:
@@ -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!);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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; });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
435
lib/pages/media_calendar_page.dart
Normal file
435
lib/pages/media_calendar_page.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
372
lib/pages/person_list_page.dart
Normal file
372
lib/pages/person_list_page.dart
Normal 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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ========== 影评相关方法 ==========
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
''');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
// ========== 回收站相关方法 ==========
|
||||
|
||||
// 获取已删除的笔记
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
237
lib/widgets/tag_side_panel.dart
Normal file
237
lib/widgets/tag_side_panel.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/widgets/text_input_panel.dart
Normal file
138
lib/widgets/text_input_panel.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user