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