添加点击 最近添加

This commit is contained in:
DelLevin-Home
2026-06-18 19:39:44 +08:00
parent 8c7631b252
commit 56dee60782
9 changed files with 338 additions and 195 deletions

View File

@@ -12,14 +12,34 @@ import 'utils/sync/auto_backup_service.dart';
import 'utils/sync/server_sync_service.dart';
import 'utils/usage_stats_service.dart';
import 'providers/app_provider.dart';
import 'package:flutter/widget_previews.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await UserPrefs.init();
final appProvider = AppProvider();
runApp(MyApp(appProvider: appProvider));
unawaited(_validateSyncOnStartup().then((_) => appProvider.initDatabase().then((_) => appProvider.initMainTabIndex())));
unawaited(_bootstrap(appProvider));
}
/// 启动后台任务:先校验同步状态,再按顺序初始化数据库与主标签,
/// 保持 UI 先渲染、数据后就绪。
Future<void> _bootstrap(AppProvider appProvider) async {
// 数据库优先初始化(不被 sync 阻塞)
try {
await appProvider.initDatabase();
} catch (e) {
debugPrint('[Startup] 数据库初始化失败: $e');
}
appProvider.initMainTabIndex();
// sync 校验在数据库加载完成后执行(避免阻塞本地数据展示)
try {
await _validateSyncOnStartup();
} catch (e) {
debugPrint('[Startup] 同步状态校验失败: $e');
}
unawaited(_initAutoBackup());
unawaited(_initUsageStats());
}
@@ -184,14 +204,3 @@ class _AppIconWrapperState extends State<_AppIconWrapper> {
return widget.child;
}
}
@Preview(name: "MookNote App Preview")
Widget previewMyApp() {
final appProvider = AppProvider();
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: appProvider),
],
child: MyApp(appProvider: appProvider),
);
}

View File

@@ -79,7 +79,7 @@ class Movie {
'id': id,
'title': title,
'poster_path': posterPath,
'release_date': releaseDate?.toUtc().toUtc().toIso8601String(),
'release_date': releaseDate?.toUtc().toIso8601String(),
'directors': jsonEncode(directors),
'writers': jsonEncode(writers),
'actors': jsonEncode(actors),

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
@@ -27,6 +26,8 @@ class _SearchPageState extends State<SearchPage> {
List<_SearchResult> _results = [];
bool _hasSearched = false;
List<String> _matchingTags = [];
String? _selectedTag;
Timer? _debounce;
@@ -64,7 +65,10 @@ class _SearchPageState extends State<SearchPage> {
if (movie.title.toLowerCase().contains(lowerKeyword) ||
movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) ||
(movie.summary?.toLowerCase().contains(lowerKeyword) ?? false) ||
movie.genres.any((g) => g.toLowerCase().contains(lowerKeyword))) {
movie.genres.any((g) => g.toLowerCase().contains(lowerKeyword)) ||
movie.directors.any((d) => d.toLowerCase().contains(lowerKeyword)) ||
movie.writers.any((w) => w.toLowerCase().contains(lowerKeyword)) ||
movie.actors.any((a) => a.toLowerCase().contains(lowerKeyword))) {
results.add(_SearchResult(type: 'movie', data: movie));
}
}
@@ -81,13 +85,61 @@ class _SearchPageState extends State<SearchPage> {
}
if (_showNotes) {
for (final note in provider.notes.where((n) => !n.isDeleted)) {
if (note.content.toLowerCase().contains(lowerKeyword) ||
if (note.title.toLowerCase().contains(lowerKeyword) ||
note.content.toLowerCase().contains(lowerKeyword) ||
note.tags.any((t) => t.toLowerCase().contains(lowerKeyword))) {
results.add(_SearchResult(type: 'note', data: note));
}
}
}
setState(() { _results = results; _hasSearched = true; });
setState(() {
_results = results;
_hasSearched = true;
// 收集匹配的标签
_matchingTags = _collectMatchingTags(lowerKeyword);
_selectedTag = null;
});
}
/// 收集所有包含关键词的标签
List<String> _collectMatchingTags(String lowerKeyword) {
final provider = context.read<AppProvider>();
final tagSet = <String>{};
for (final m in provider.movies.where((m) => !m.isDeleted)) {
for (final g in m.genres) {
if (g.toLowerCase().contains(lowerKeyword)) tagSet.add(g);
}
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
for (final g in b.genres) {
if (g.toLowerCase().contains(lowerKeyword)) tagSet.add(g);
}
}
for (final n in provider.notes.where((n) => !n.isDeleted)) {
for (final t in n.tags) {
if (t.toLowerCase().contains(lowerKeyword)) tagSet.add(t);
}
}
return tagSet.toList()..sort();
}
/// 按标签筛选:点击标签后只显示包含该标签的结果
void _filterByTag(String tag) {
final provider = context.read<AppProvider>();
setState(() {
_selectedTag = tag;
final results = <_SearchResult>[];
for (final m in provider.movies.where((m) => !m.isDeleted)) {
if (m.genres.contains(tag)) results.add(_SearchResult(type: 'movie', data: m));
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
if (b.genres.contains(tag)) results.add(_SearchResult(type: 'book', data: b));
}
for (final n in provider.notes.where((n) => !n.isDeleted)) {
if (n.tags.contains(tag)) results.add(_SearchResult(type: 'note', data: n));
}
_results = results;
});
}
// ═══════════════════════════════════════════════════════════════════
@@ -107,7 +159,7 @@ class _SearchPageState extends State<SearchPage> {
_buildFilterRow(),
Expanded(
child: _hasSearched
? _results.isEmpty ? _buildEmptyState() : _buildResultList()
? _results.isEmpty && _matchingTags.isEmpty ? _buildEmptyState() : _buildResultList()
: _buildInitialState(),
),
]),
@@ -155,9 +207,9 @@ class _SearchPageState extends State<SearchPage> {
int movieCount = 0, bookCount = 0, noteCount = 0;
if (keyword.isNotEmpty) {
final kw = keyword.toLowerCase();
movieCount = provider.movies.where((m) => !m.isDeleted && (m.title.toLowerCase().contains(kw) || m.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (m.summary?.toLowerCase().contains(kw) ?? false) || m.genres.any((g) => g.toLowerCase().contains(kw)))).length;
movieCount = provider.movies.where((m) => !m.isDeleted && (m.title.toLowerCase().contains(kw) || m.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (m.summary?.toLowerCase().contains(kw) ?? false) || m.genres.any((g) => g.toLowerCase().contains(kw)) || m.directors.any((d) => d.toLowerCase().contains(kw)) || m.writers.any((w) => w.toLowerCase().contains(kw)) || m.actors.any((a) => a.toLowerCase().contains(kw)))).length;
bookCount = provider.books.where((b) => !b.isDeleted && (b.title.toLowerCase().contains(kw) || b.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (b.summary?.toLowerCase().contains(kw) ?? false) || b.authors.any((a) => a.toLowerCase().contains(kw)))).length;
noteCount = provider.notes.where((n) => !n.isDeleted && (n.content.toLowerCase().contains(kw) || n.tags.any((t) => t.toLowerCase().contains(kw)))).length;
noteCount = provider.notes.where((n) => !n.isDeleted && (n.title.toLowerCase().contains(kw) || n.content.toLowerCase().contains(kw) || n.tags.any((t) => t.toLowerCase().contains(kw)))).length;
}
return Padding(
@@ -206,7 +258,7 @@ class _SearchPageState extends State<SearchPage> {
const SizedBox(height: 20),
Text('输入关键词搜索', style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 4),
Text('支持标题、作者、标签、类型', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.2))),
Text('支持标题、导演、作者、标签、简介', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.2))),
]),
);
}
@@ -231,30 +283,85 @@ class _SearchPageState extends State<SearchPage> {
Widget _buildResultList() {
final colors = Theme.of(context).colorScheme;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 标签筛选区
if (_matchingTags.isNotEmpty) _buildTagSection(colors),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Text('找到 ${_results.length} 条结果',
child: Text(_selectedTag != null
? '标签 "$_selectedTag" 共 ${_results.length}'
: '找到 ${_results.length} 条结果',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
switch (item.type) {
case 'movie': return _buildMovieItem(item.data as Movie);
case 'book': return _buildBookItem(item.data as Book);
case 'note': return _buildNoteItem(item.data as Note);
default: return const SizedBox.shrink();
}
},
),
child: _results.isEmpty
? _buildEmptyState()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
switch (item.type) {
case 'movie': return _buildMovieItem(item.data as Movie);
case 'book': return _buildBookItem(item.data as Book);
case 'note': return _buildNoteItem(item.data as Note);
default: return const SizedBox.shrink();
}
},
),
),
]);
}
Widget _buildTagSection(ColorScheme colors) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.label_outline, size: 14, color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 4),
Text('匹配标签', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
if (_selectedTag != null) ...[
const Spacer(),
GestureDetector(
onTap: () { setState(() { _selectedTag = null; }); _performSearch(); },
child: Text('清除筛选', style: TextStyle(fontSize: 12, color: colors.primary)),
),
],
]),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
children: _matchingTags.map((tag) {
final isSelected = _selectedTag == tag;
return GestureDetector(
onTap: () => _filterByTag(tag),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? colors.primary : colors.outline.withValues(alpha: 0.15),
),
),
child: Text(tag, style: TextStyle(
fontSize: 12,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
),
);
}).toList(),
),
],
),
);
}
Widget _buildMovieItem(Movie movie) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(

View File

@@ -1,7 +1,7 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'dart:typed_data';
import '../models/data_models.dart';
/// 数据库帮助类 - 管理数据库的创建和版本控制
@@ -341,11 +341,11 @@ class DatabaseHelper {
'updated_at': row['updated_at']?.toString() ?? now,
});
} catch (e) {
// 忽略迁移失败的记录
debugPrint('[DB] 迁移笔记记录失败: $e');
}
}
}
/// 升级books表到V3
Future<void> _upgradeBooksTableV3(Database db) async {
// 备份旧数据
@@ -393,7 +393,7 @@ class DatabaseHelper {
'is_deleted': 0,
});
} catch (e) {
// 忽略迁移失败的记录
debugPrint('[DB] 迁移书籍记录失败: $e');
}
}
}
@@ -448,7 +448,7 @@ class DatabaseHelper {
'is_deleted': row['is_deleted'] ?? 0,
});
} catch (e) {
// 忽略迁移失败的记录
debugPrint('[DB] 迁移影视记录失败: $e');
}
}
}

View File

@@ -361,8 +361,9 @@ class ServerDataService {
final size = exists ? await file.length() : 0;
debugPrint('[Sync] 图片: $relPath (存在=$exists 大小=$size)');
if (exists) {
request.files.add(await http.MultipartFile(
'images', file.readAsBytes().asStream(), size,
final bytes = await file.readAsBytes();
request.files.add(http.MultipartFile.fromBytes(
'images', bytes,
filename: relPath,
));
}

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
@@ -312,18 +311,22 @@ class ServerSyncService {
final deviceId = _prefs.deviceId;
final appDir = (await getApplicationDocumentsDirectory()).path;
// 上传数据库
// 上传数据库(先关闭连接再读取,避免文件被占用)
await DatabaseHelper.instance.close();
final dbPath = await DatabaseHelper.instance.databasePath;
final request = http.MultipartRequest('POST', Uri.parse('$url/api/sync/upload'));
request.fields['code'] = code;
request.fields['device_id'] = deviceId;
if (dbPath != null && File(dbPath).existsSync()) {
request.files.add(await http.MultipartFile(
'database', File(dbPath).readAsBytes().asStream(), await File(dbPath).length(),
final dbBytes = await File(dbPath).readAsBytes();
request.files.add(http.MultipartFile.fromBytes(
'database', dbBytes,
filename: 'mooknote.db',
));
debugPrint('[Sync] 上传数据库: ${await File(dbPath).length()} bytes');
debugPrint('[Sync] 上传数据库: ${dbBytes.length} bytes');
}
// 重新打开数据库
await DatabaseHelper.instance.reopen();
// 收集并上传所有图片
final imagePaths = _collectAllImageFiles(appDir);
@@ -331,8 +334,9 @@ class ServerSyncService {
for (final path in imagePaths) {
final relPath = p.relative(path, from: appDir).replaceAll('\\', '/');
if (await File(path).exists()) {
request.files.add(await http.MultipartFile(
'images', File(path).readAsBytes().asStream(), await File(path).length(),
final bytes = await File(path).readAsBytes();
request.files.add(http.MultipartFile.fromBytes(
'images', bytes,
filename: relPath,
));
}

View File

@@ -8,6 +8,10 @@ import '../pages/stroll_page.dart';
import '../pages/markdown_reader/md_reader_tab_page.dart';
import '../pages/tag_management_page.dart';
import '../pages/profile_page.dart';
import '../pages/movies/movie_detail_page.dart';
import '../pages/book/book_detail_page.dart';
import '../pages/note/note_detail_page.dart';
import '../models/data_models.dart';
import 'fade_in_local_image.dart';
/// 自定义侧边栏
@@ -368,22 +372,26 @@ class _CustomDrawerState extends State<CustomDrawer> {
],
),
const SizedBox(height: 14),
...recent.take(4).map((item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Icon(
item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : Icons.note_outlined,
size: 14, color: colors.onSurface.withValues(alpha: 0.3),
),
const SizedBox(width: 10),
Expanded(
child: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75))),
),
const SizedBox(width: 8),
Text(_recentTimeAgo(item.date), style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
],
...recent.take(4).map((item) => InkWell(
onTap: () => _openRecentItem(context, item),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.only(bottom: 10, top: 2),
child: Row(
children: [
Icon(
item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : Icons.note_outlined,
size: 14, color: colors.onSurface.withValues(alpha: 0.3),
),
const SizedBox(width: 10),
Expanded(
child: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75))),
),
const SizedBox(width: 8),
Text(_recentTimeAgo(item.date), style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
],
),
),
)),
],
@@ -396,18 +404,30 @@ class _CustomDrawerState extends State<CustomDrawer> {
List<_RecentItem> _getRecentItems(AppProvider provider) {
final items = <_RecentItem>[];
for (final m in provider.movies.where((m) => !m.isDeleted)) {
items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt));
items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt, data: m));
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt));
items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b));
}
for (final n in provider.notes.where((n) => !n.isDeleted)) {
items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt));
items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt, data: n));
}
items.sort((a, b) => b.date.compareTo(a.date));
return items;
}
void _openRecentItem(BuildContext context, _RecentItem item) {
Navigator.pop(context);
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 _recentTimeAgo(DateTime date) {
final diff = DateTime.now().difference(date);
if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
@@ -422,5 +442,6 @@ class _RecentItem {
final String type;
final String title;
final DateTime date;
_RecentItem({required this.type, required this.title, required this.date});
final dynamic data;
_RecentItem({required this.type, required this.title, required this.date, required this.data});
}