diff --git a/README.md b/README.md
index 3688ed0..46f8dad 100644
--- a/README.md
+++ b/README.md
@@ -1,139 +1,158 @@
-# MookNote - 极简风格的观影阅读笔记应用
+# MookNote
-## 项目简介
-MookNote 是一款用于记录观影、阅读和笔记的 Android 软件,基于 Flutter 框架开发。
+极简风格的观影 · 阅读 · 笔记 记录应用,基于 Flutter 开发。
-## 功能
-- 影视增删改查
-- 书籍增删改查
-- 笔记增删改查
-- 影评增删改查
-- 书评增删改
-- 豆瓣分享链接爬取
-- 支持使用网络图片
-- 添加清除无效图片功能
-- 优化左侧边栏
+## 应用预览
-## 环境要求
+| 影视列表 | 书籍列表 | 笔记界面 |
+|:---:|:---:|:---:|
+|
|
|
|
-### 必需软件
-1. **Flutter SDK** (建议 3.5.0+)
- - 下载地址:https://docs.flutter.dev/get-started/install
-
-2. **Android Studio**
- - 下载地址:https://developer.android.com/studio
-
-3. **Android SDK** (通过 Android Studio 安装)
-### 环境配置步骤
+更多预览图片请到应用预览2里面查看
-#### Windows 系统
+## 功能特性
-1. **下载并安装 Flutter SDK**
- ```
- - 下载 Flutter SDK zip 文件
- - 解压到 C:\src\flutter(或其他非系统目录)
- - 将 C:\src\flutter\bin 添加到系统环境变量 Path
- ```
+### 影视管理
+- 影视增删改查,支持海报、导演、演员、类型等信息录入
+- 影评撰写与管理,支持星级评分
+- 影视海报墙浏览(瀑布流布局)
+- 影视分享(生成海报名场面风格分享卡片)
+- 豆瓣链接爬取,自动填充影视信息
+- 影视状态筛选(想看 / 在看 / 已看)
-2. **验证 Flutter 安装**
- ```bash
- flutter --version
- flutter doctor
- ```
+### 书籍管理
+- 书籍增删改查,支持封面、作者、出版社等信息
+- 书评撰写与管理
+- 书摘 / 摘录记录
+- 书籍分享卡片
+- 书籍状态筛选
-3. **安装 Android Studio**
- ```
- - 下载并安装 Android Studio
- - 打开 Android Studio → Settings → Appearance & Behavior → System Settings → Android SDK
- - 安装 Android SDK Platform(建议 API 33+)
- - 安装 Android SDK Build-Tools
- - 安装 Android Emulator(可选,用于模拟器测试)
- ```
+### 笔记管理
+- 笔记增删改查,支持 Markdown 编辑与实时渲染
+- 笔记分享卡片
+- Markdown 阅读器支持暗色模式与字体大小调节
-4. **接受 Android 许可证**
-
- ```bash
- flutter doctor --android-licenses
- ```
-
-## 运行项目
-
-### 1. 克隆/复制项目后,进入项目目录
-```bash
-cd mooknote
-```
-
-### 2. 安装依赖
-
-```bash
-flutter pub get
-```
-
-### 3. 连接设备或启动模拟器
-
-- 连接 Android 真机(需开启 USB 调试)
-- 或启动 Android 模拟器
-
-### 4. 运行应用
-```bash
-flutter run
-```
-
-或使用 Android Studio:
-- 打开项目
-- 点击运行按钮 (Run)
-
-### 其他命令
-
-```bash
-# 清理依赖
-flutter clean
-# 获取依赖
-flutter pub get
-# 如果失败可以先添加代理再进行get
-$env:HTTP_PROXY="http://127.0.0.1:10808"
-$env:HTTPS_PROXY="http://127.0.0.1:10808"
-flutter pub get
-# 运行
-flutter run
-# 构建 APK
-flutter build apk --release
-# 或构建 App Bundle(推荐用于 Google Play)
-flutter build appbundle --release
-# 使用国内镜像构建运行
-$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
-$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
-flutter pub get
-flutter run
-```
-
-## 用户统计
-```
-/// usage_stats_service.dart文件
-/// 统计服务器地址,发布前替换为实际地址,置空则禁用
-static String serverUrl = 'http://192.168.31.48:5000';
-```
+### 通用功能
+- 全局搜索(影视 / 书籍 / 笔记)
+- 标签管理与分类
+- 数据统计与可视化图表(月度趋势 / 类型分布)
+- 回收站(软删除,可恢复)
+- 服务器同步与 WebDAV 云同步
+- 自动备份
+- 暗色 / 亮色主题切换
+- 自定义应用图标
+- 更新日志查看
+- 用户使用统计(可选)
## 技术栈
-- **Flutter**: 跨平台移动应用框架
-- **Dart**: 编程语言
-- **Provider**: 状态管理
+| 类别 | 技术 |
+|------|------|
+| 框架 | Flutter 3.5+ |
+| 语言 | Dart |
+| 状态管理 | Provider |
+| 本地数据库 | SQLite(sqflite) |
+
+## 项目结构
+
+```
+lib/
+├── main.dart # 应用入口,初始化与后台任务
+├── models/
+│ └── data_models.dart # 数据模型(Movie, Book, Note 等)
+├── providers/
+│ └── app_provider.dart # 全局状态管理(Provider)
+├── pages/
+│ ├── movies/ # 影视相关页面
+│ ├── book/ # 书籍相关页面
+│ ├── note/ # 笔记相关页面
+│ ├── markdown_reader/ # Markdown 阅读器
+│ ├── sync/ # 同步与备份页面
+│ ├── home_page.dart # 首页
+│ ├── search_page.dart # 搜索页
+│ ├── statistics_page.dart # 数据统计页
+│ ├── profile_page.dart # 个人中心
+│ └── recycle_bin_page.dart # 回收站
+├── utils/
+│ ├── database_helper.dart # SQLite 数据库管理
+│ ├── movie/ # 影视 DAO
+│ ├── book/ # 书籍 DAO
+│ ├── note/ # 笔记 DAO
+│ ├── tag/ # 标签 DAO
+│ ├── sync/ # 同步服务(Server / WebDAV / 备份)
+│ ├── theme/ # 主题配置
+│ └── user_prefs.dart # 用户偏好设置
+└── widgets/ # 通用组件
+```
+
+## 数据存储
+
+- **数据库**:`/mooknote/mooknote.db`
+- **图片**:`/mooknote/images/<类别>/<条目ID>/<文件名>`
+ - 类别:`movie` / `book` / `note`
+
+## 环境要求
+
+- Flutter SDK 3.5+
+- Dart SDK 3.5+
+- Android SDK(API 21+)
+
+## 快速开始
+
+```bash
+# 克隆项目
+git clone https://github.com/dellevin/mooknote.git
+cd mooknote
+
+# 安装依赖
+flutter pub get
+
+# 运行
+flutter run
+```
+
+如果 `pub get` 失败,可尝试设置临时变量的国内镜像:
+
+```bash
+$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
+$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
+flutter pub get
+```
+
+或者使用代理方式:
+
+```bash
+# 如果失败可以先添加代理再进行get,ip和端口号自行更改
+$env:HTTP_PROXY="http://127.0.0.1:10808"
+$env:HTTPS_PROXY="http://127.0.0.1:10808"
+flutter pub get
+```
+
+## 构建
+
+```bash
+# 构建 Release APK
+flutter build apk --release
+
+# 构建 App Bundle(Google Play)
+flutter build appbundle --release
+```
+
+## 应用预览2
+
+| 数据统计 | 侧边栏 | 我的界面 |
+|:---:|:---:|:---:|
+|
|
|
|
+| 详情界面1 | 详情界面2 | 编辑笔记 |
+|:---:|:---:|:---:|
+|
|
|
|
-**5. 图片文件存储路径:**
-
-- 数据库:`/mooknote/mooknote.db`
--
-- 图片:`/mooknote/images/类别(影视/图书/笔记)/类别下的条目id/图片文件名`
-
-**注意:**
-
-- 目前图片同步是基于文件存在性判断,不是基于修改时间
-- 下载新数据库后,应用会自动重新加载数据(调用 Provider 的 load 方法)
-- 首次同步会创建远程目录结构
+| 分享界面1 | 分享界面2 | 预览笔记 |
+|:---:|:---:|:---:|
+|
|
|
|
## 开源协议
diff --git a/assets/README/Screenshot_1781866417.png b/assets/README/Screenshot_1781866417.png
new file mode 100644
index 0000000..4c851e5
Binary files /dev/null and b/assets/README/Screenshot_1781866417.png differ
diff --git a/assets/README/Screenshot_1781866427-1781866809535-2.png b/assets/README/Screenshot_1781866427-1781866809535-2.png
new file mode 100644
index 0000000..02bc7f4
Binary files /dev/null and b/assets/README/Screenshot_1781866427-1781866809535-2.png differ
diff --git a/assets/README/Screenshot_1781866427.png b/assets/README/Screenshot_1781866427.png
new file mode 100644
index 0000000..02bc7f4
Binary files /dev/null and b/assets/README/Screenshot_1781866427.png differ
diff --git a/lib/main.dart b/lib/main.dart
index 1db6ed0..7cabcc0 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
import 'pages/home_page.dart';
import 'utils/theme/app_theme.dart';
import 'utils/app_router.dart';
import 'utils/user_prefs.dart';
+import 'utils/changelog_service.dart';
import 'utils/sync/auto_backup_service.dart';
import 'utils/sync/server_sync_service.dart';
import 'utils/usage_stats_service.dart';
@@ -101,6 +103,8 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State with WidgetsBindingObserver {
ThemeMode? _lastAppliedTheme;
+ bool _updateCheckDone = false;
+ final GlobalKey _navigatorKey = GlobalKey();
@override
void initState() {
@@ -108,8 +112,76 @@ class _MyAppState extends State with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
widget.appProvider.loadThemeMode();
widget.appProvider.addListener(_onThemeChanged);
- // 延迟到首帧后确保生效
- WidgetsBinding.instance.addPostFrameCallback((_) => _applySystemUI());
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _applySystemUI();
+ _checkUpdate(); // 不阻塞,完成后自行弹窗
+ });
+ }
+
+ /// 延迟到首页渲染后再检查版本更新,确保 context 已就绪
+ Future _checkUpdate() async {
+ if (_updateCheckDone) return;
+ _updateCheckDone = true;
+ await Future.delayed(const Duration(milliseconds: 500));
+ var ctx = _navigatorKey.currentContext;
+ if (ctx == null || !ctx.mounted) return;
+ try {
+ final hasUpdate = await ChangelogService.hasUpdate();
+ if (!hasUpdate) return;
+ final latestVersion = await ChangelogService.fetchLatestVersion();
+ if (latestVersion == null) return;
+ final dismissed = UserPrefs().dismissedVersion;
+ if (dismissed == latestVersion) return;
+ ctx = _navigatorKey.currentContext;
+ if (ctx == null || !ctx.mounted) return;
+ _showUpdateDialog(ctx, latestVersion);
+ } catch (_) {}
+ }
+
+ void _showUpdateDialog(BuildContext context, String version) {
+ if (!context.mounted) return;
+ final colors = Theme.of(context).colorScheme;
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: colors.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: Row(children: [
+ Icon(Icons.system_update_outlined, color: colors.primary, size: 24),
+ const SizedBox(width: 10),
+ Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
+ ]),
+ content: Text('新版本 $version 已发布,是否下载更新?',
+ style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
+ actions: [
+ TextButton(
+ onPressed: () {
+ UserPrefs().setDismissedVersion(version);
+ Navigator.pop(ctx);
+ },
+ child: Text('不再显示', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.5))),
+ ),
+ const SizedBox(width: 8),
+ FilledButton.icon(
+ onPressed: () async {
+ Navigator.pop(ctx);
+ try {
+ await launchUrl(Uri.parse('https://mooknote.iletter.top/#/'),
+ mode: LaunchMode.externalApplication);
+ } catch (_) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('链接失效')));
+ }
+ }
+ },
+ icon: const Icon(Icons.open_in_browser, size: 18),
+ label: const Text('去官网下载'),
+ ),
+ ],
+ actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
+ ),
+ );
}
void _onThemeChanged() {
@@ -160,7 +232,7 @@ class _MyAppState extends State with WidgetsBindingObserver {
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: provider.themeMode,
- localizationsDelegates: [
+ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -170,6 +242,7 @@ class _MyAppState extends State with WidgetsBindingObserver {
Locale('en', 'US'),
],
home: const HomePage(),
+ navigatorKey: _navigatorKey,
onGenerateRoute: AppRouter.generateRoute,
builder: (context, child) {
return _AppIconWrapper(iconName: iconName, child: child!);
diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart
index c773378..0a2d7c5 100644
--- a/lib/pages/book/book_detail_page.dart
+++ b/lib/pages/book/book_detail_page.dart
@@ -629,7 +629,7 @@ class _BookDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -662,7 +662,7 @@ class _BookDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -695,7 +695,7 @@ class _BookDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -728,7 +728,7 @@ class _BookDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -760,7 +760,7 @@ class _BookDetailPageState extends State {
Widget _buildGenresSection(Book book) {
final isOverlay = _detailStyle == 1;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
diff --git a/lib/pages/changelog_page.dart b/lib/pages/changelog_page.dart
new file mode 100644
index 0000000..adffdf1
--- /dev/null
+++ b/lib/pages/changelog_page.dart
@@ -0,0 +1,306 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:url_launcher/url_launcher.dart';
+import '../utils/changelog_service.dart';
+
+/// 更新日志页面
+class ChangelogPage extends StatefulWidget {
+ const ChangelogPage({super.key});
+
+ @override
+ State createState() => _ChangelogPageState();
+}
+
+class _ChangelogPageState extends State {
+ List? _items;
+ bool _loading = true;
+ bool _checking = false;
+ static const _websiteUrl = 'https://mooknote.iletter.top/#/';
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ final items = await ChangelogService.fetchChangelog();
+ if (mounted) setState(() { _items = items; _loading = false; });
+ }
+
+ Future _checkUpdate() async {
+ setState(() => _checking = true);
+ try {
+ final hasUpdate = await ChangelogService.hasUpdate();
+ if (!mounted) return;
+ final info = await PackageInfo.fromPlatform();
+ final localVersion = 'v${info.version}';
+ if (!mounted) return;
+ if (hasUpdate) {
+ final latest = _items != null && _items!.isNotEmpty
+ ? _items!.first.version
+ : '新版本';
+ final latestVersion = await ChangelogService.fetchLatestVersion();
+ _showUpdateDialog(
+ version: latestVersion ?? latest,
+ localVersion: localVersion,
+ );
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(
+ content: Text('已是最新版本(当前 $localVersion)'),
+ duration: const Duration(seconds: 2),
+ ));
+ }
+ } catch (_) {}
+ if (mounted) setState(() => _checking = false);
+ }
+
+ void _showUpdateDialog({required String version, String? localVersion}) {
+ final colors = Theme.of(context).colorScheme;
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: colors.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: Row(children: [
+ Icon(Icons.system_update_outlined, color: colors.primary, size: 24),
+ const SizedBox(width: 10),
+ Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
+ ]),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (localVersion != null) ...[
+ Text('当前版本:$localVersion',
+ style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
+ const SizedBox(height: 6),
+ ],
+ Text('最新版本 $version 已发布,是否下载更新?',
+ 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.5))),
+ ),
+ const SizedBox(width: 8),
+ FilledButton.icon(
+ onPressed: () async {
+ Navigator.pop(ctx);
+ try {
+ await launchUrl(Uri.parse(_websiteUrl), mode: LaunchMode.externalApplication);
+ } catch (_) {}
+ },
+ icon: const Icon(Icons.open_in_browser, size: 18),
+ label: const Text('去官网下载'),
+ ),
+ ],
+ actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ return Scaffold(
+ backgroundColor: colors.surface,
+ appBar: AppBar(
+ title: const Text('更新日志'),
+ actions: [
+ _checking
+ ? const Padding(
+ padding: EdgeInsets.only(right: 16),
+ child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
+ : IconButton(
+ icon: const Icon(Icons.refresh, size: 20),
+ tooltip: '检查更新',
+ onPressed: _checkUpdate,
+ ),
+ ],
+ ),
+ body: _loading
+ ? const Center(child: CircularProgressIndicator())
+ : _items == null || _items!.isEmpty
+ ? Center(
+ child: Text('暂无更新日志',
+ style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))))
+ : ListView(
+ padding: const EdgeInsets.all(20),
+ children: [
+ _buildWebsiteCard(colors),
+ const SizedBox(height: 20),
+ ..._items!.map((item) => Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: _buildCard(item, colors),
+ )),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildWebsiteCard(ColorScheme colors) {
+ return Container(
+ decoration: BoxDecoration(
+ color: colors.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
+ child: Row(
+ children: [
+ Icon(Icons.language, size: 18, color: colors.primary),
+ const SizedBox(width: 8),
+ Text('官方网站',
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
+ ],
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: GestureDetector(
+ onTap: () async {
+ try {
+ await launchUrl(Uri.parse(_websiteUrl), mode: LaunchMode.externalApplication);
+ } catch (_) {}
+ },
+ child: Text(_websiteUrl,
+ style: TextStyle(fontSize: 13, color: colors.primary)),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Divider(height: 1, color: colors.outlineVariant),
+ Row(
+ children: [
+ Expanded(
+ child: InkWell(
+ onTap: () {
+ Clipboard.setData(const ClipboardData(text: _websiteUrl));
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
+ content: Text('已复制到剪贴板'),
+ duration: Duration(seconds: 1),
+ ));
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ alignment: Alignment.center,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.copy, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
+ const SizedBox(width: 6),
+ Text('复制链接',
+ style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
+ ],
+ ),
+ ),
+ ),
+ ),
+ Container(width: 1, height: 24, color: colors.outlineVariant),
+ Expanded(
+ child: InkWell(
+ onTap: () async {
+ try {
+ await launchUrl(Uri.parse(_websiteUrl), mode: LaunchMode.externalApplication);
+ } catch (_) {}
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ alignment: Alignment.center,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.open_in_browser, size: 16, color: colors.primary),
+ const SizedBox(width: 6),
+ Text('浏览器打开',
+ style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.primary)),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildCard(ChangelogItem item, ColorScheme colors) {
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: colors.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: colors.primary,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ item.version,
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: colors.onPrimary,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Text(
+ item.date,
+ style: TextStyle(
+ fontSize: 13,
+ color: colors.onSurface.withValues(alpha: 0.5),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 14),
+ ...item.features.map((f) => Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ margin: const EdgeInsets.only(top: 6),
+ width: 6,
+ height: 6,
+ decoration: BoxDecoration(
+ color: colors.primary.withValues(alpha: 0.4),
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ f,
+ style: TextStyle(
+ fontSize: 14,
+ color: colors.onSurface.withValues(alpha: 0.75),
+ height: 1.5,
+ ),
+ ),
+ ),
+ ],
+ ),
+ )),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart
index 1b7da82..512f31a 100644
--- a/lib/pages/movies/movie_detail_page.dart
+++ b/lib/pages/movies/movie_detail_page.dart
@@ -222,59 +222,45 @@ class _MovieDetailPageState extends State {
);
}
- /// 叠层模式:封面小图 + 标题/评分(毛玻璃卡片)
+ /// 叠层模式:封面小图 + 标题/评分
Widget _buildOverlayHeader(Movie movie) {
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
- return ClipRRect(
- borderRadius: BorderRadius.circular(16),
- child: BackdropFilter(
- filter: ui.ImageFilter.blur(sigmaX: 15, sigmaY: 15),
- child: Container(
- width: double.infinity,
- padding: const EdgeInsets.all(16),
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 100, height: 140,
decoration: BoxDecoration(
- color: Colors.white.withValues(alpha: 0.08),
- borderRadius: BorderRadius.circular(16),
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Container(
- width: 100, height: 140,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))],
- ),
- clipBehavior: Clip.antiAlias,
- child: hasPoster
- ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover)
- : Container(color: Colors.white24, child: const Icon(Icons.movie_outlined, color: Colors.white38, size: 32)),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- const SizedBox(height: 4),
- Text(movie.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)),
- if (movie.directors.isNotEmpty) ...[
- const SizedBox(height: 6),
- Text('导演:${movie.directors.join(' / ')}', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))),
- ],
- const SizedBox(height: 12),
- Row(children: [
- if (movie.rating != null && movie.rating! > 0) ...[
- const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)),
- const SizedBox(width: 4),
- Text(movie.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
- const SizedBox(width: 16),
- ],
- _statusChip(movie.status),
- ]),
- ]),
- ),
- ],
+ borderRadius: BorderRadius.circular(8),
+ boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))],
),
+ clipBehavior: Clip.antiAlias,
+ child: hasPoster
+ ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover)
+ : Container(color: Colors.white24, child: const Icon(Icons.movie_outlined, color: Colors.white38, size: 32)),
),
- ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ const SizedBox(height: 4),
+ Text(movie.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)),
+ if (movie.directors.isNotEmpty) ...[
+ const SizedBox(height: 6),
+ Text('导演:${movie.directors.join(' / ')}', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))),
+ ],
+ const SizedBox(height: 12),
+ Row(children: [
+ if (movie.rating != null && movie.rating! > 0) ...[
+ const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)),
+ const SizedBox(width: 4),
+ Text(movie.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
+ const SizedBox(width: 16),
+ ],
+ _statusChip(movie.status),
+ ]),
+ ]),
+ ),
+ ],
);
}
@@ -660,7 +646,7 @@ class _MovieDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -693,7 +679,7 @@ class _MovieDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -726,7 +712,7 @@ class _MovieDetailPageState extends State {
final isOverlay = _detailStyle == 1;
final colors = Theme.of(context).colorScheme;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -758,7 +744,7 @@ class _MovieDetailPageState extends State {
Widget _buildGenresSection(Movie movie) {
final isOverlay = _detailStyle == 1;
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart
index 53e0018..c836bc1 100644
--- a/lib/pages/profile_page.dart
+++ b/lib/pages/profile_page.dart
@@ -12,6 +12,7 @@ import 'recycle_bin_page.dart';
import 'sync/backup_page.dart';
import '../widgets/fade_in_local_image.dart';
import 'statistics_page.dart';
+import 'changelog_page.dart';
import 'sync/cloud_sync_page.dart';
import 'app_icon_picker_page.dart';
import 'tag_management_page.dart';
@@ -498,6 +499,13 @@ class _SettingsPageState extends State {
url: 'https://mooknote.iletter.top/#/guide',
),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
+ _buildActionItem(
+ icon: Icons.update_outlined,
+ title: '更新日志',
+ subtitle: '查看版本更新内容',
+ onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangelogPage())),
+ ),
+ Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
],
),
);
diff --git a/lib/utils/changelog_service.dart b/lib/utils/changelog_service.dart
new file mode 100644
index 0000000..6617943
--- /dev/null
+++ b/lib/utils/changelog_service.dart
@@ -0,0 +1,91 @@
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:package_info_plus/package_info_plus.dart';
+
+/// 更新日志数据模型
+class ChangelogItem {
+ final String version;
+ final String date;
+ final List features;
+
+ ChangelogItem({
+ required this.version,
+ required this.date,
+ required this.features,
+ });
+
+ factory ChangelogItem.fromJson(Map json) {
+ return ChangelogItem(
+ version: json['version'] ?? '',
+ date: json['date'] ?? '',
+ features: (json['features'] as List?)
+ ?.map((e) => e.toString())
+ .toList() ??
+ [],
+ );
+ }
+}
+
+/// 版本更新检查服务
+class ChangelogService {
+ static const _apiUrl = 'https://api.mooknote.iletter.top/api/changelog';
+
+ /// 获取更新日志列表
+ static Future> fetchChangelog() async {
+ try {
+ final resp = await http
+ .get(Uri.parse(_apiUrl))
+ .timeout(const Duration(seconds: 5));
+ if (resp.statusCode == 200) {
+ final data = jsonDecode(resp.body);
+ final items = (data['items'] as List?)
+ ?.map((e) => ChangelogItem.fromJson(e as Map))
+ .toList() ??
+ [];
+ return items;
+ }
+ } catch (_) {}
+ return [];
+ }
+
+ /// 获取最新版本号
+ static Future fetchLatestVersion() async {
+ final items = await fetchChangelog();
+ if (items.isNotEmpty) return items.first.version;
+ return null;
+ }
+
+ /// 比较两版本号,a > b 则返回 1,a < b 返回 -1,相等返回 0
+ /// v0.1.9 → 当成数字 "0.19" = 0.19,0.1.88 → "0.188" = 0.188,所以 0.19 > 0.188
+ /// 实现方式:去掉 v,把第一个点后的数字拼接再转 double 比较
+ static int compareVersion(String a, String b) {
+ double toNum(String v) {
+ final s = v.replaceFirst('v', '');
+ final dot = s.indexOf('.');
+ if (dot == -1) return double.tryParse(s) ?? 0;
+ // "0.1.9" → "0." + "19" = "0.19";"0.1.88" → "0." + "188" = "0.188"
+ final major = s.substring(0, dot + 1); // "0."
+ final rest = s.substring(dot + 1).replaceAll('.', ''); // "19" 或 "188"
+ return double.tryParse('$major$rest') ?? 0;
+ }
+ final aVal = toNum(a);
+ final bVal = toNum(b);
+ debugPrint('[Update] compare: "$a"→$aVal vs "$b"→$bVal');
+ if (aVal > bVal) return 1;
+ if (aVal < bVal) return -1;
+ return 0;
+ }
+
+ /// 检查是否有新版本(远程 > 本地)
+ static Future hasUpdate() async {
+ final latest = await fetchLatestVersion();
+ if (latest == null) return false;
+ final info = await PackageInfo.fromPlatform();
+ final local = 'v${info.version}';
+ debugPrint('[Update] 远程: $latest, 本地: $local');
+ final result = compareVersion(latest, local) > 0;
+ debugPrint('[Update] 远程 > 本地? $result');
+ return result;
+ }
+}
diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart
index 2e80979..7b5409f 100644
--- a/lib/utils/user_prefs.dart
+++ b/lib/utils/user_prefs.dart
@@ -154,4 +154,10 @@ class UserPrefs {
/// 上次同步到的 entry id
int get syncLastEntryId => prefs.getInt('syncLastEntryId') ?? 0;
Future setSyncLastEntryId(int value) => prefs.setInt('syncLastEntryId', value);
+
+ // ========== 版本更新 ==========
+
+ /// 已忽略的版本号(不再提示更新)
+ String get dismissedVersion => prefs.getString('dismissedVersion') ?? '';
+ Future setDismissedVersion(String value) => prefs.setString('dismissedVersion', value);
}
diff --git a/proj-img/Screenshot_1781866410.png b/proj-img/Screenshot_1781866410.png
new file mode 100644
index 0000000..812e9a7
Binary files /dev/null and b/proj-img/Screenshot_1781866410.png differ
diff --git a/proj-img/Screenshot_1781866417.png b/proj-img/Screenshot_1781866417.png
new file mode 100644
index 0000000..4c851e5
Binary files /dev/null and b/proj-img/Screenshot_1781866417.png differ
diff --git a/proj-img/Screenshot_1781866427.png b/proj-img/Screenshot_1781866427.png
new file mode 100644
index 0000000..02bc7f4
Binary files /dev/null and b/proj-img/Screenshot_1781866427.png differ
diff --git a/proj-img/Screenshot_1781866432.png b/proj-img/Screenshot_1781866432.png
new file mode 100644
index 0000000..3446056
Binary files /dev/null and b/proj-img/Screenshot_1781866432.png differ
diff --git a/proj-img/Screenshot_1781866436.png b/proj-img/Screenshot_1781866436.png
new file mode 100644
index 0000000..f8616b9
Binary files /dev/null and b/proj-img/Screenshot_1781866436.png differ
diff --git a/proj-img/Screenshot_1781866461.png b/proj-img/Screenshot_1781866461.png
new file mode 100644
index 0000000..be8bd99
Binary files /dev/null and b/proj-img/Screenshot_1781866461.png differ
diff --git a/proj-img/bj1.png b/proj-img/bj1.png
new file mode 100644
index 0000000..8d67cee
Binary files /dev/null and b/proj-img/bj1.png differ
diff --git a/proj-img/bj2.png b/proj-img/bj2.png
new file mode 100644
index 0000000..9c4b910
Binary files /dev/null and b/proj-img/bj2.png differ
diff --git a/proj-img/fx1.png b/proj-img/fx1.png
new file mode 100644
index 0000000..53dfbe6
Binary files /dev/null and b/proj-img/fx1.png differ
diff --git a/proj-img/fx2.png b/proj-img/fx2.png
new file mode 100644
index 0000000..f073488
Binary files /dev/null and b/proj-img/fx2.png differ
diff --git a/proj-img/xq1.png b/proj-img/xq1.png
new file mode 100644
index 0000000..5fbc872
Binary files /dev/null and b/proj-img/xq1.png differ
diff --git a/proj-img/xq2.png b/proj-img/xq2.png
new file mode 100644
index 0000000..0d0f631
Binary files /dev/null and b/proj-img/xq2.png differ