9 Commits
46 changed files with 2764 additions and 186 deletions
+2
View File
@@ -53,3 +53,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.pi/
+105
View File
@@ -0,0 +1,105 @@
# Changelog
## 1.0.2 - 2026-07-15
### Summary
- Feature release adding timeline gap display options, project archiving, and in-app update detection.
### New Features
- Added three inter-record gap display modes (previous / earliest / both) with a persisted global setting defaulting to both.
- Added project archiving: archived projects become read-only with a grayscale overlay and a checkmark indicator; archived projects can be hidden or shown from the project management menu and are excluded from statistics.
- Added remote update detection: fetches a JSON manifest, compares per-ABI versionCode, and on Android downloads, verifies (SHA256), and installs the matching ABI APK in-app; iOS shows an explicit unsupported notice.
### Changed
- Replaced the record voice-comment icon with HugeIcons Mic02 (size 21) for visual consistency.
### Fixed
- Fixed a null assertion crash when removing a record's background image on the add-record page.
- Fixed update detection version comparison to use per-ABI versionCode so split-per-ABI clients correctly detect updates and avoid perpetual update loops.
### Build
- Bumped Flutter app version to 1.0.2+3 for the v1.0.2 release tag.
### Assets
- Package Name: com.ooopos.line
- App Version: 1.0.2+3
- Signature Check: apksigner verify --verbose --print-certs <apk>
- Release assets (split-per-ABI):
| Asset | ABI | Size (bytes) | SHA256 |
|---|---|---:|---|
| app-armeabi-v7a-release.apk | armeabi-v7a | 20704616 | bc5f6254484443207c86a3376ba413fecd2717bcaf1fee09e6a8a3947ee7a227 |
| app-arm64-v8a-release.apk | arm64-v8a | 22634962 | 6c8efa0bae2395647e8d676f76d3bb187183f389f3538b7e0d8f2e6579e7278b |
| app-x86_64-release.apk | x86_64 | 23807057 | 1d42e5f06a931ad4fcd2129d287996a63830247bcee0b75f1f6aa85a1d495311 |
### References
- d821f47 feat(timeline): add inter-record gap display options and fix background image removal
- 2ce1fd8 feat(project): add archiving with read-only mode and grayscale overlay
- 4d66e7a feat(update): add remote update check with in-app APK install
- d132170 fix(update): compare per-ABI versionCode for correct update detection
- 212d600 build(release): bump version to 1.0.2+3
## 1.0.1 - 2026-04-05
### Summary
- Delivered a compatibility patch release after the package identifier migration in `v1.0.0`.
### Fixed
- Migrated legacy backup media paths so restored assets continue to resolve after the package rename to `com.ooopos.line`.
### Build
- Bumped Flutter app version to `1.0.1+2` for the `v1.0.1` release tag.
### Assets
- Package Name: com.ooopos.line
- App Version: 1.0.1+2
- Signature Check: apksigner verify --verbose --print-certs <apk>
- Release assets (split-per-ABI):
| Asset | ABI | Size (bytes) | SHA256 |
|---|---|---:|---|
| app-armeabi-v7a-release.apk | armeabi-v7a | 20580607 | 99fdafea9c2bbf3ea1d54622d036ef96aec6564ea94a80ee4644cd9ad40a24aa |
| app-arm64-v8a-release.apk | arm64-v8a | 22560105 | ce47cb6bd67c992190665e3074de4269935364bee474e2032469ec907ce8b740 |
| app-x86_64-release.apk | x86_64 | 23732200 | 2819cf18906e0b2323f7b9ae3e8f4ea6b5107a4f60c008c1cc8c5e5049fcc47a |
### References
- c06547b fix(backup): migrate legacy media paths after package rename
- 09003c2 build(release): bump version to 1.0.1+2
## 1.0.0 - 2026-04-05
### Summary
- Shipped the first tagged production release with voice comments, release signing hardening, and package identifier migration.
### New Features
- Added local voice comment recording and playback flow for timeline comments.
### Changed
- Rewrote project README files to match current product scope and usage.
### Fixed
- Aligned project search and sorting behavior and synchronized related OpenSpec spec artifacts.
### Build
- Enforced environment-driven release signing variables.
- Migrated Android application identifier to `com.ooopos.line`.
### Assets
- Package Name: com.ooopos.line
- App Version: 1.0.0+1
- Signature Check: apksigner verify --verbose --print-certs <apk>
- Release assets (split-per-ABI):
| Asset | ABI | Size (bytes) | SHA256 |
|---|---|---:|---|
| app-armeabi-v7a-release.apk | armeabi-v7a | 20580607 | 572357e7e2c6762703bb4e71c0562c1b3a9fca5af73cf4bea3ed5c4655c5847e |
| app-arm64-v8a-release.apk | arm64-v8a | 22560105 | 8470efafbf049edd7439787350d4393bc1c93e1720470845b2ec6594c8842992 |
| app-x86_64-release.apk | x86_64 | 23732200 | 7a335f3ce18f42025676710d6f871c70272637216f9d8513860558b76b3fddac |
### References
- 214b677 chore(repo): initialize repository with curated ignore rules
- df58f31 fix(project): align search-sort flows and sync openspec specs
- 66a23ab chore(repo): ignore local metadata and remove bundled font asset
- e0e7715 docs(readme): rewrite README based on current project status
- 0a3cad3 feat(comment): add local voice comment recording and playback
- 4d0e630 build(release): enforce env signing and migrate app id to com.ooopos.line
+3
View File
@@ -13,6 +13,9 @@
<!-- 媒体权限 (Android 13+) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- APK 安装权限 (应用内更新) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="Enil"
android:name="${applicationName}"
+51
View File
@@ -0,0 +1,51 @@
# Update Check Manifest
The app fetches a static JSON manifest from the repo to detect new versions and (on Android) download + install the matching ABI APK.
## Manifest location
The manifest is served as a raw file from the Gitea remote:
```
https://git.ooopos.site/agnoy/line/raw/branch/main/update.json
```
The branch (`main`) is configured as `manifestUrl` in `lib/services/update_check_service.dart`. If the default branch changes, update that constant.
## Schema
| Field | Type | Required | Notes |
|--------------------------|---------|----------|-----------------------------------------------------------------------|
| `latestVersion` | string | yes | Human-readable version name, e.g. `"1.0.2"`. |
| `versionCode` | int | yes | Base build number (the `+N` part of `pubspec.yaml`). Used as comparison key on iOS; on Android each asset carries its own per-ABI `versionCode`. |
| `minRequiredVersionCode` | int | no | Reserved for future forced-update enforcement. Not enforced yet. |
| `releaseNotes` | string | yes | Release notes, `\n`-separated lines. Plain text (rendering may come later). |
| `publishDate` | string | no | ISO date, e.g. `"2026-07-14"`. |
| `assets` | array | yes | Per-ABI APK descriptors (see below). |
### Asset object
| Field | Type | Required | Notes |
|--------------|--------|----------|-----------------------------------------------------------------------------------------------|
| `abi` | string | yes | One of `arm64-v8a`, `armeabi-v7a`, `x86_64`. |
| `versionCode`| int | yes | Per-ABI versionCode for this APK. Formula: armeabi-v7a `1000+N`, arm64-v8a `2000+N`, x86_64 `4000+N` where N is the build number. Compared against the installed per-ABI versionCode on Android. |
| `url` | string | yes | Direct download URL for the APK. |
| `sha256` | string | yes | Hex SHA256 of the APK file. Verified before install. |
| `size` | int | yes | File size in bytes (informational). |
## Per-release update steps
1. Bump `version` and the `+versionCode` in `pubspec.yaml`.
2. Build per-ABI release APKs (`flutter build apk --split-per-abi`).
3. Compute each APK's SHA256 (e.g. `shasum -a 256 app-*-release.apk`).
4. Upload the APKs to the Gitea release.
5. Update `update.json` at the repo root: `latestVersion`, `versionCode` (base build number), `releaseNotes`, `publishDate`, and each asset's `versionCode` (per-ABI) / `url` / `sha256` / `size`.
6. Commit and push `update.json` so the raw URL serves the new manifest.
## Client behavior
- On Android, the asset whose `abi` matches the device's first supported ABI is chosen; its per-ABI `versionCode` is compared against the installed per-ABI versionCode. No match raises an explicit error.
- On iOS, the manifest top-level `versionCode` (base build number) is compared against the installed build number.
- The downloaded APK's SHA256 is verified before opening the OS installer; a mismatch aborts install.
- On iOS, update info + notes are shown with an explicit "in-app install not supported" notice (no silent no-op).
- Failures (fetch, parse, ABI match, SHA256, install consent) surface explicitly; the app never reports a fake "up to date".
+41
View File
@@ -12,7 +12,9 @@ import 'viewmodels/template_viewmodel.dart';
import 'views/project/project_list_page.dart';
import 'views/root/bottom_tab_bar_home.dart';
import 'views/setting/agreement_dialog.dart';
import 'views/setting/update_check_dialog.dart';
import 'services/first_launch_service.dart';
import 'services/update_check_service.dart';
import 'config/theme_colors.dart';
void main() {
@@ -75,6 +77,43 @@ class _MainAppState extends State<MainApp> {
);
}
/// Background auto-check for updates on launch. Best-effort and non-blocking:
/// failures are logged and never crash startup. Honors the auto-check toggle
/// and a 24h rate limit, and notifies at most once per new version.
Future<void> _maybeAutoCheckForUpdates(BuildContext context) async {
try {
final enabled = await UpdateCheckPrefs.autoCheckEnabled();
if (!enabled) return;
if (!await UpdateCheckPrefs.shouldAutoCheck()) return;
await UpdateCheckPrefs.markAutoChecked();
final result = await UpdateCheckService().checkForUpdates();
if (!result.updateAvailable) return;
final lastNotified = await UpdateCheckPrefs.lastNotifiedVersionCode();
if (lastNotified == result.manifest.versionCode) return;
await UpdateCheckPrefs.setLastNotifiedVersionCode(
result.manifest.versionCode,
);
if (!mounted || !context.mounted) return;
final scaffoldMessenger = ScaffoldMessenger.of(context);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('发现新版本 ${result.manifest.latestVersion}'),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: '查看',
onPressed: () =>
UpdateCheckDialog.showUpdateAvailable(context, result),
),
),
);
} catch (e) {
log('Auto update check failed: $e', name: 'MainApp.autoCheck');
}
}
@override
Widget build(BuildContext context) {
log('Building main app with providers');
@@ -163,6 +202,8 @@ class _MainAppState extends State<MainApp> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_hasCheckedAgreement && !_userHasAgreed) {
_showAgreementDialog(context);
} else if (_hasCheckedAgreement && _userHasAgreed) {
_maybeAutoCheckForUpdates(context);
}
});
+8
View File
@@ -27,6 +27,9 @@ class Project {
/// 项目最后更新时间
final DateTime updatedAt;
/// 项目是否已归档(归档后只读)
final bool isArchived;
/// 构造函数
Project({
this.id,
@@ -37,6 +40,7 @@ class Project {
this.tagIds = const [],
required this.createdAt,
required this.updatedAt,
this.isArchived = false,
}) {
log(
'Project created: title="$title", images=${imageList.length}, tags=${tagIds.length}',
@@ -56,6 +60,7 @@ class Project {
'description': description,
'image_list': imageList.join(','), // 将列表转换为逗号分隔的字符串
'background_image': backgroundImage, // 背景图片路径
'is_archived': isArchived ? 1 : 0, // 归档状态
'created_at': createdAt.millisecondsSinceEpoch,
'updated_at': updatedAt.millisecondsSinceEpoch,
};
@@ -95,6 +100,7 @@ class Project {
imageList: imageList,
backgroundImage: map['background_image'] ?? '', // 背景图片路径,处理null值
tagIds: const [], // 标签通过关联表获取,这里设为空列表
isArchived: (map['is_archived'] == 1), // 归档状态,兼容旧数据默认为未归档
createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at']),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at']),
);
@@ -115,6 +121,7 @@ class Project {
List<int>? tagIds,
DateTime? createdAt,
DateTime? updatedAt,
bool? isArchived,
}) {
log(
'Creating copy of Project: original title="${this.title}"',
@@ -129,6 +136,7 @@ class Project {
tagIds: tagIds ?? this.tagIds,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isArchived: isArchived ?? this.isArchived,
);
log(
'Project copy created: "${newProject.title}"',
+182 -37
View File
@@ -31,7 +31,7 @@ class BackupService {
Future<String?> exportData([BuildContext? context]) async {
try {
log('Starting data export');
// 请求存储权限
PermissionStatus permission;
if (Platform.isAndroid) {
@@ -48,7 +48,7 @@ class BackupService {
} else {
permission = await Permission.storage.request();
}
if (!permission.isGranted) {
log('Storage permission denied');
if (context != null && context.mounted) {
@@ -66,10 +66,15 @@ class BackupService {
// 获取图片文件夹路径
final imagesDir = await _getImagesDirectory();
// 创建临时目录用于组织文件
final tempDir = await getTemporaryDirectory();
final exportTempDir = Directory(path.join(tempDir.path, 'line_export_${DateTime.now().millisecondsSinceEpoch}'));
final exportTempDir = Directory(
path.join(
tempDir.path,
'line_export_${DateTime.now().millisecondsSinceEpoch}',
),
);
await exportTempDir.create(recursive: true);
// 关闭当前数据库连接
@@ -84,7 +89,9 @@ class BackupService {
// 复制图片文件夹
if (imagesDir != null && await imagesDir.exists()) {
final exportImagesDir = Directory(path.join(exportTempDir.path, 'images'));
final exportImagesDir = Directory(
path.join(exportTempDir.path, 'images'),
);
await exportImagesDir.create();
await _copyDirectory(imagesDir, exportImagesDir);
log('Images directory copied');
@@ -95,12 +102,13 @@ class BackupService {
await _addDirectoryToArchive(archive, exportTempDir, '');
// 生成导出文件名
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.')[0];
final timestamp =
DateTime.now().toIso8601String().replaceAll(':', '-').split('.')[0];
final fileName = 'line_backup_$timestamp.zip';
// 生成ZIP文件字节数据
final zipBytes = Uint8List.fromList(ZipEncoder().encode(archive));
// 保存文件
final selectedPath = await FilePicker.platform.saveFile(
dialogTitle: '选择保存位置',
@@ -109,12 +117,12 @@ class BackupService {
allowedExtensions: ['zip'],
bytes: zipBytes,
);
if (selectedPath == null) {
log('User cancelled file save');
return null;
}
log('Export completed: $selectedPath');
return selectedPath;
} finally {
@@ -133,10 +141,13 @@ class BackupService {
/// [filePath] 备份文件路径
/// [onRestoreComplete] 恢复完成后的回调函数,用于通知UI刷新
/// 返回恢复是否成功
Future<bool> restoreData(String filePath, {Function()? onRestoreComplete}) async {
Future<bool> restoreData(
String filePath, {
Function()? onRestoreComplete,
}) async {
try {
log('Starting data restore from: $filePath');
final backupFile = File(filePath);
if (!await backupFile.exists()) {
log('Backup file not found');
@@ -149,7 +160,12 @@ class BackupService {
// 创建临时目录用于解压
final tempDir = await getTemporaryDirectory();
final restoreTempDir = Directory(path.join(tempDir.path, 'line_restore_${DateTime.now().millisecondsSinceEpoch}'));
final restoreTempDir = Directory(
path.join(
tempDir.path,
'line_restore_${DateTime.now().millisecondsSinceEpoch}',
),
);
await restoreTempDir.create(recursive: true);
try {
@@ -168,13 +184,15 @@ class BackupService {
await _closeDatabaseConnection();
// 恢复数据库文件
final restoredDbFile = File(path.join(restoreTempDir.path, 'timeline.db'));
final restoredDbFile = File(
path.join(restoreTempDir.path, 'timeline.db'),
);
if (await restoredDbFile.exists()) {
final currentDbPath = await _getDatabasePath();
if (currentDbPath != null) {
await restoredDbFile.copy(currentDbPath);
log('Database restored');
// 强制重新初始化数据库连接
await DatabaseService().forceReinitializeDatabase();
log('Database connection reinitialized');
@@ -182,7 +200,9 @@ class BackupService {
}
// 恢复图片文件夹
final restoredImagesDir = Directory(path.join(restoreTempDir.path, 'images'));
final restoredImagesDir = Directory(
path.join(restoreTempDir.path, 'images'),
);
if (await restoredImagesDir.exists()) {
final currentImagesDir = await _getImagesDirectory();
if (currentImagesDir != null) {
@@ -191,20 +211,21 @@ class BackupService {
await currentImagesDir.delete(recursive: true);
}
await currentImagesDir.create(recursive: true);
// 复制恢复的图片
await _copyDirectory(restoredImagesDir, currentImagesDir);
log('Images restored');
}
}
await _migrateLegacyPackagePathsInDatabase();
log('Restore completed successfully');
// 调用恢复完成回调
if (onRestoreComplete != null) {
onRestoreComplete();
}
return true;
} finally {
// 清理临时目录
@@ -260,6 +281,126 @@ class BackupService {
}
}
Future<void> _migrateLegacyPackagePathsInDatabase() async {
const legacyPackageName = 'com.example.line';
final appDocDir = await getApplicationDocumentsDirectory();
final currentAppDocPath = appDocDir.path;
final currentPackageName = _extractPackageNameFromAppDocPath(
currentAppDocPath,
);
if (currentPackageName.isEmpty || currentPackageName == legacyPackageName) {
log('Skip legacy path migration, package=$currentPackageName');
return;
}
final replacements = <MapEntry<String, String>>[
MapEntry(
'/data/user/0/$legacyPackageName/app_flutter',
currentAppDocPath,
),
MapEntry('/data/data/$legacyPackageName/app_flutter', currentAppDocPath),
MapEntry(
'/storage/emulated/0/Android/data/$legacyPackageName/files',
'/storage/emulated/0/Android/data/$currentPackageName/files',
),
MapEntry(
'/sdcard/Android/data/$legacyPackageName/files',
'/sdcard/Android/data/$currentPackageName/files',
),
MapEntry('/$legacyPackageName/', '/$currentPackageName/'),
];
final targets = <String, List<String>>{
'projects': ['image_list', 'background_image'],
'timeline_records': ['image_list', 'background_image'],
'comments': ['image_list', 'voice_path', 'image_path'],
'music_booths': ['image_path'],
};
final db = await DatabaseService().database;
int totalUpdatedRows = 0;
for (final entry in targets.entries) {
final table = entry.key;
if (!await _tableExists(db, table)) {
continue;
}
for (final column in entry.value) {
if (!await _columnExists(db, table, column)) {
continue;
}
for (final replacement in replacements) {
final updatedRows = await _replacePathInColumn(
db: db,
table: table,
column: column,
fromValue: replacement.key,
toValue: replacement.value,
);
totalUpdatedRows += updatedRows;
}
}
}
log('Legacy path migration completed: $totalUpdatedRows rows updated');
}
String _extractPackageNameFromAppDocPath(String appDocPath) {
final normalizedPath = appDocPath.replaceAll('\\', '/');
const marker = '/app_flutter';
final markerIndex = normalizedPath.indexOf(marker);
if (markerIndex <= 0) {
return '';
}
final prefix = normalizedPath.substring(0, markerIndex);
final lastSlashIndex = prefix.lastIndexOf('/');
if (lastSlashIndex < 0 || lastSlashIndex == prefix.length - 1) {
return '';
}
return prefix.substring(lastSlashIndex + 1);
}
Future<int> _replacePathInColumn({
required Database db,
required String table,
required String column,
required String fromValue,
required String toValue,
}) async {
if (fromValue == toValue) {
return 0;
}
return db.rawUpdate(
'UPDATE $table '
'SET $column = REPLACE($column, ?, ?) '
'WHERE $column IS NOT NULL AND INSTR($column, ?) > 0',
[fromValue, toValue, fromValue],
);
}
Future<bool> _tableExists(Database db, String table) async {
final rows = await db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
[table],
);
return rows.isNotEmpty;
}
Future<bool> _columnExists(Database db, String table, String column) async {
final rows = await db.rawQuery('PRAGMA table_info($table)');
for (final row in rows) {
if (row['name']?.toString() == column) {
return true;
}
}
return false;
}
/// 显示权限请求对话框
Future<void> _showPermissionDialog(BuildContext context) async {
return showDialog<void>(
@@ -277,9 +418,7 @@ class BackupService {
const Text('需要存储权限'),
],
),
content: const Text(
'应用需要存储权限来导出备份文件。请在设置中授予存储权限。',
),
content: const Text('应用需要存储权限来导出备份文件。请在设置中授予存储权限。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
@@ -291,7 +430,8 @@ class BackupService {
// 尝试直接跳转到权限设置页面
if (Platform.isAndroid) {
// 先尝试请求管理外部存储权限
var permission = await Permission.manageExternalStorage.request();
var permission =
await Permission.manageExternalStorage.request();
if (!permission.isGranted) {
// 如果管理外部存储权限被拒绝,尝试普通存储权限
permission = await Permission.storage.request();
@@ -325,11 +465,16 @@ class BackupService {
}
/// 将目录添加到压缩包
Future<void> _addDirectoryToArchive(Archive archive, Directory dir, String basePath) async {
Future<void> _addDirectoryToArchive(
Archive archive,
Directory dir,
String basePath,
) async {
await for (final entity in dir.list()) {
final relativePath = path.relative(entity.path, from: dir.path);
final archivePath = basePath.isEmpty ? relativePath : path.join(basePath, relativePath);
final archivePath =
basePath.isEmpty ? relativePath : path.join(basePath, relativePath);
if (entity is File) {
final bytes = await entity.readAsBytes();
final archiveFile = ArchiveFile(archivePath, bytes.length, bytes);
@@ -341,14 +486,14 @@ class BackupService {
}
}
/// 关闭数据库连接
Future<void> _closeDatabaseConnection() async {
try {
// 通过获取数据库实例然后关闭
final db = await DatabaseService().database;
await db.close();
log('Database connection closed');
} catch (e) {
log('Error closing database: $e');
}
}
/// 关闭数据库连接
Future<void> _closeDatabaseConnection() async {
try {
// 通过获取数据库实例然后关闭
final db = await DatabaseService().database;
await db.close();
log('Database connection closed');
} catch (e) {
log('Error closing database: $e');
}
}
+9 -1
View File
@@ -61,7 +61,7 @@ class DatabaseService {
log('Initializing database at path: $path');
return await openDatabase(
path,
version: 11,
version: 12,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -94,6 +94,7 @@ class DatabaseService {
description TEXT NOT NULL,
image_list TEXT,
background_image TEXT,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
@@ -413,6 +414,13 @@ class DatabaseService {
'ALTER TABLE comments ADD COLUMN voice_size_bytes INTEGER',
);
}
if (oldVersion < 12) {
// 为项目表添加归档状态字段
await db.execute(
'ALTER TABLE projects ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0',
);
}
}
/// 项目相关操作
+301
View File
@@ -0,0 +1,301 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:open_filex/open_filex.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Update check explicit error. All failures surface as this type so the UI
/// can show them rather than silently degrading.
class UpdateCheckException implements Exception {
final String message;
UpdateCheckException(this.message);
@override
String toString() => message;
}
/// A per-ABI APK descriptor from the manifest.
class UpdateAsset {
final String abi;
final String url;
final String sha256;
final int size;
final int versionCode;
UpdateAsset({
required this.abi,
required this.url,
required this.sha256,
required this.size,
required this.versionCode,
});
factory UpdateAsset.fromJson(Map<String, dynamic> json) {
final abi = json['abi'];
final url = json['url'];
final sha256 = json['sha256'];
final size = json['size'];
final versionCode = json['versionCode'];
if (abi is! String || url is! String || sha256 is! String) {
throw UpdateCheckException('更新清单资源字段缺失或类型错误');
}
if (versionCode is! num) {
throw UpdateCheckException('更新清单资源缺少 versionCode 字段');
}
return UpdateAsset(
abi: abi,
url: url,
sha256: sha256,
size: size is num ? size.toInt() : 0,
versionCode: versionCode.toInt(),
);
}
}
/// The remote update manifest.
class UpdateManifest {
final String latestVersion;
final int versionCode;
final int? minRequiredVersionCode;
final String releaseNotes;
final String? publishDate;
final List<UpdateAsset> assets;
UpdateManifest({
required this.latestVersion,
required this.versionCode,
this.minRequiredVersionCode,
required this.releaseNotes,
this.publishDate,
required this.assets,
});
factory UpdateManifest.fromJson(Map<String, dynamic> json) {
final latestVersion = json['latestVersion'];
final versionCode = json['versionCode'];
final releaseNotes = json['releaseNotes'];
if (latestVersion is! String) {
throw UpdateCheckException('更新清单缺少 latestVersion 字段');
}
if (versionCode is! num) {
throw UpdateCheckException('更新清单缺少 versionCode 字段');
}
if (releaseNotes is! String) {
throw UpdateCheckException('更新清单缺少 releaseNotes 字段');
}
final rawAssets = json['assets'];
if (rawAssets is! List || rawAssets.isEmpty) {
throw UpdateCheckException('更新清单缺少 assets 资源列表');
}
final assets = rawAssets
.map((e) => UpdateAsset.fromJson(e as Map<String, dynamic>))
.toList();
final minRequired = json['minRequiredVersionCode'];
return UpdateManifest(
latestVersion: latestVersion,
versionCode: versionCode.toInt(),
minRequiredVersionCode: minRequired is num ? minRequired.toInt() : null,
releaseNotes: releaseNotes,
publishDate: json['publishDate'] as String?,
assets: assets,
);
}
}
/// Result of an update check.
class UpdateCheckResult {
final bool updateAvailable;
final UpdateManifest manifest;
final int installedVersionCode;
final String installedVersion;
UpdateCheckResult({
required this.updateAvailable,
required this.manifest,
required this.installedVersionCode,
required this.installedVersion,
});
}
/// Fetches the remote update manifest, compares versions, and (on Android)
/// downloads, verifies, and installs the matching ABI APK.
///
/// All failures throw [UpdateCheckException]; the caller must surface them.
class UpdateCheckService {
static const String manifestUrl =
'https://git.ooopos.site/agnoy/line/raw/branch/main/update.json';
final Dio _dio;
UpdateCheckService({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
));
/// Reads the installed build's version name and version code.
Future<({String version, int versionCode})> installedVersion() async {
final info = await PackageInfo.fromPlatform();
final code = int.tryParse(info.buildNumber);
if (code == null) {
throw UpdateCheckException('无法解析当前版本号: ${info.buildNumber}');
}
return (version: info.version, versionCode: code);
}
/// Fetches the manifest and determines whether an update is available.
Future<UpdateCheckResult> checkForUpdates() async {
final installed = await installedVersion();
Response<dynamic> resp;
try {
resp = await _dio.get<dynamic>(manifestUrl);
} on DioException catch (e) {
throw UpdateCheckException('拉取更新清单失败: ${e.message ?? e.type.name}');
}
if (resp.statusCode != 200) {
throw UpdateCheckException('拉取更新清单失败: HTTP ${resp.statusCode}');
}
final data = resp.data;
final Map<String, dynamic> jsonMap;
if (data is String) {
try {
jsonMap = jsonDecode(data) as Map<String, dynamic>;
} catch (e) {
throw UpdateCheckException('更新清单 JSON 解析失败: $e');
}
} else if (data is Map<String, dynamic>) {
jsonMap = data;
} else {
throw UpdateCheckException('更新清单格式不符合预期');
}
final manifest = UpdateManifest.fromJson(jsonMap);
final bool updateAvailable;
if (Platform.isAndroid) {
final match = await _findMatchingAsset(manifest);
if (match == null) {
throw UpdateCheckException('未找到匹配当前设备 ABI 的安装包');
}
updateAvailable = match.versionCode > installed.versionCode;
} else {
updateAvailable = manifest.versionCode > installed.versionCode;
}
return UpdateCheckResult(
updateAvailable: updateAvailable,
manifest: manifest,
installedVersionCode: installed.versionCode,
installedVersion: installed.version,
);
}
/// Whether the current platform supports in-app APK install.
bool get supportsInAppInstall => Platform.isAndroid;
/// Finds the manifest asset matching the device's highest-priority ABI.
/// Returns null on non-Android platforms or when no ABI matches.
Future<UpdateAsset?> _findMatchingAsset(UpdateManifest manifest) async {
if (!Platform.isAndroid) return null;
final info = await DeviceInfoPlugin().androidInfo;
final supportedAbis = info.supportedAbis;
for (final abi in supportedAbis) {
final match = manifest.assets.where((a) => a.abi == abi).firstOrNull;
if (match != null) return match;
}
return null;
}
/// Selects the manifest asset matching the device's highest-priority ABI.
Future<UpdateAsset> selectAssetForDevice(UpdateManifest manifest) async {
if (!Platform.isAndroid) {
throw UpdateCheckException('当前平台不支持按 ABI 选择安装包');
}
final match = await _findMatchingAsset(manifest);
if (match == null) {
throw UpdateCheckException('未找到匹配当前设备 ABI 的安装包');
}
return match;
}
/// Downloads the asset, verifies its SHA256, and triggers the OS installer.
/// Throws [UpdateCheckException] on any failure (network, integrity, install).
Future<void> downloadAndInstall(
UpdateAsset asset, {
void Function(int received, int total)? onProgress,
}) async {
if (!Platform.isAndroid) {
throw UpdateCheckException('当前平台不支持应用内安装');
}
final dir = await getTemporaryDirectory();
final filePath = '${dir.path}/enil_update_${asset.abi}.apk';
try {
await _dio.download(asset.url, filePath, onReceiveProgress: onProgress);
} on DioException catch (e) {
throw UpdateCheckException('下载安装包失败: ${e.message ?? e.type.name}');
}
final file = File(filePath);
if (!await file.exists()) {
throw UpdateCheckException('下载文件未生成');
}
final digest = sha256.convert(await file.readAsBytes());
if (digest.toString() != asset.sha256) {
try {
await file.delete();
} catch (_) {}
throw UpdateCheckException(
'安装包校验失败: SHA256 不匹配(期望 ${asset.sha256},实际 $digest)',
);
}
final result = await OpenFilex.open(filePath);
if (result.type != ResultType.done) {
throw UpdateCheckException('无法打开系统安装器: ${result.message}');
}
}
}
/// Persists update-check preferences: the auto-check toggle and the last
/// `versionCode` the user was notified about (for once-per-version notices).
class UpdateCheckPrefs {
static const _autoCheckKey = 'update_auto_check_enabled';
static const _lastNotifiedKey = 'update_last_notified_version_code';
static const _lastAutoCheckAtKey = 'update_last_auto_check_at_ms';
static const _autoCheckIntervalMs = 24 * 60 * 60 * 1000; // 24h
static Future<bool> autoCheckEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoCheckKey) ?? true;
}
static Future<void> setAutoCheckEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoCheckKey, enabled);
}
static Future<int> lastNotifiedVersionCode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_lastNotifiedKey) ?? 0;
}
static Future<void> setLastNotifiedVersionCode(int code) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_lastNotifiedKey, code);
}
/// Whether enough time has passed since the last auto-check (24h rate limit).
static Future<bool> shouldAutoCheck() async {
final prefs = await SharedPreferences.getInstance();
final last = prefs.getInt(_lastAutoCheckAtKey) ?? 0;
return DateTime.now().millisecondsSinceEpoch - last >= _autoCheckIntervalMs;
}
static Future<void> markAutoChecked() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_lastAutoCheckAtKey, DateTime.now().millisecondsSinceEpoch);
}
}
+24
View File
@@ -77,6 +77,9 @@ class ProjectViewModel extends ChangeNotifier {
/// 获取项目排序顺序
ProjectSortOrder get sortOrder => _sortOrder;
/// 获取已归档项目数量
int get archivedCount => _projects.where((p) => p.isArchived).length;
/// 加载所有项目(优化版)
/// 从数据库获取项目列表并更新状态
/// [forceRefresh] 是否强制刷新,默认false
@@ -587,6 +590,11 @@ class ProjectViewModel extends ChangeNotifier {
List<Project> activeProjects = [];
for (final project in _projects) {
// 归档项目不参与活跃统计
if (project.isArchived) {
log('Project "${project.title}" is archived, skipping active check');
continue;
}
try {
// 获取项目的最新记录时间
final latestRecordTime = await getProjectDisplayTime(project);
@@ -622,4 +630,20 @@ class ProjectViewModel extends ChangeNotifier {
final activeProjects = await getActiveProjects();
return activeProjects.length;
}
/// 归档项目(归档后变为只读)
/// [project] 要归档的项目对象
Future<void> archiveProject(Project project) async {
if (project.id == null) return;
log('Archiving project: ${project.title} (ID: ${project.id})');
await updateProject(project.copyWith(isArchived: true));
}
/// 取消归档项目(恢复可编辑)
/// [project] 要取消归档的项目对象
Future<void> unarchiveProject(Project project) async {
if (project.id == null) return;
log('Unarchiving project: ${project.title} (ID: ${project.id})');
await updateProject(project.copyWith(isArchived: false));
}
}
+34
View File
@@ -9,6 +9,9 @@ import '../utils/font_loader.dart';
/// 主题模式枚举
enum ThemeMode { light, dark, system }
/// 时间间隔显示模式枚举
enum GapMode { previous, first, both }
/// 主题管理ViewModel
class ThemeViewModel extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
@@ -23,6 +26,7 @@ class ThemeViewModel extends ChangeNotifier {
bool _useBottomTabBarStyle = false; // 控制界面风格:false为底部操作区域,true为底部导航栏
bool _showCommentBackground = false; // 控制是否显示评论背景色
bool _useCircularEmojiStyle = false; // 控制emoji和时间间隔容器是否使用圆形样式
GapMode _timelineGapMode = GapMode.both; // 控制时间间隔显示模式
// 自定义主题颜色
Color? _lightWhite; // 浅色主题色
@@ -44,6 +48,7 @@ class ThemeViewModel extends ChangeNotifier {
bool get useBottomTabBarStyle => _useBottomTabBarStyle;
bool get showCommentBackground => _showCommentBackground;
bool get useCircularEmojiStyle => _useCircularEmojiStyle;
GapMode get timelineGapMode => _timelineGapMode;
// 自定义主题颜色getter
Color? get lightWhite => _lightWhite;
@@ -116,6 +121,17 @@ class ThemeViewModel extends ChangeNotifier {
// 加载圆形emoji样式设置
_useCircularEmojiStyle =
prefs.getBool('use_circular_emoji_style') ?? false;
// 加载时间间隔显示模式
final savedGapMode =
prefs.getString('timeline_gap_mode') ?? 'both';
if (savedGapMode == 'first') {
_timelineGapMode = GapMode.first;
} else if (savedGapMode == 'both') {
_timelineGapMode = GapMode.both;
} else {
_timelineGapMode = GapMode.previous;
}
// 加载自定义主题颜色
_lightWhite = _colorFromString(prefs.getString('custom_light_white'));
@@ -471,6 +487,24 @@ class ThemeViewModel extends ChangeNotifier {
notifyListeners();
}
/// 设置时间间隔显示模式
Future<void> setTimelineGapMode(GapMode mode) async {
if (_timelineGapMode == mode) return;
_timelineGapMode = mode;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('timeline_gap_mode', mode.name);
} catch (e) {
if (kDebugMode) {
print('Failed to save timeline gap mode preferences: $e');
}
}
notifyListeners();
}
/// 设置浅色主题色
Future<void> setLightWhite(Color? color) async {
+104 -31
View File
@@ -40,6 +40,7 @@ class _ProjectListPageState extends State<ProjectListPage> {
String _searchQuery = '';
List<Project> _filteredProjects = [];
bool _isSearchMode = false;
bool _hideArchived = false;
int _searchRequestSequence = 0;
@override
@@ -232,10 +233,14 @@ class _ProjectListPageState extends State<ProjectListPage> {
Expanded(
child: Consumer<ProjectViewModel>(
builder: (context, viewModel, child) {
final projects =
final rawProjects =
_isSearchMode
? _filteredProjects
: viewModel.projects;
final projects =
_hideArchived && !_isSearchMode
? rawProjects.where((p) => !p.isArchived).toList()
: rawProjects;
if (projects.isEmpty && !viewModel.isLoading) {
return Center(
@@ -664,10 +669,29 @@ class _ProjectListPageState extends State<ProjectListPage> {
ProjectViewModel viewModel,
) {
log('Showing project options for: ${project.title} (ID: ${project.id})');
final isArchived = project.isArchived;
BottomSheetWidget.show(
title: '项目管理',
context: context,
options: [
if (isArchived)
BottomSheetOption(
icon: Icon(CupertinoIcons.arrow_counterclockwise_circle),
title: '取消归档',
onTap: () {
Navigator.pop(context);
viewModel.unarchiveProject(project);
},
)
else
BottomSheetOption(
icon: Icon(CupertinoIcons.archivebox),
title: '归档',
onTap: () {
Navigator.pop(context);
viewModel.archiveProject(project);
},
),
BottomSheetOption(
icon: Icon(CupertinoIcons.square_on_square),
title: '复制',
@@ -684,28 +708,30 @@ class _ProjectListPageState extends State<ProjectListPage> {
_navigateToProjectExport(context, project);
},
),
BottomSheetOption(
icon: Icon(CupertinoIcons.pencil_circle),
title: '编辑',
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => EditProjectPage(project: project),
),
);
},
),
BottomSheetOption.delete(
context: context,
icon: Icon(CupertinoIcons.delete),
title: '删除',
onTap: () {
Navigator.pop(context);
_showDeleteDialog(context, project, viewModel);
},
),
if (!isArchived) ...[
BottomSheetOption(
icon: Icon(CupertinoIcons.pencil_circle),
title: '编辑',
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => EditProjectPage(project: project),
),
);
},
),
BottomSheetOption.delete(
context: context,
icon: Icon(CupertinoIcons.delete),
title: '删除',
onTap: () {
Navigator.pop(context);
_showDeleteDialog(context, project, viewModel);
},
),
],
BottomSheetOption.cancel(
context: context,
onTap: () => Navigator.pop(context),
@@ -845,7 +871,7 @@ class _ProjectListPageState extends State<ProjectListPage> {
// 在搜索模式下,直接使用传入的project参数
// 在非搜索模式下,确保索引有效
final currentProject =
_isSearchMode
(_isSearchMode || _hideArchived)
? project
: (index < viewModel.projects.length
? viewModel.projects[index]
@@ -853,9 +879,34 @@ class _ProjectListPageState extends State<ProjectListPage> {
return GestureDetector(
onTap: () => _navigateToTimeline(context, currentProject),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
child: _buildProjectCardBody(
context,
viewModel,
themeViewModel,
tagViewModel,
currentProject,
),
);
},
);
}
Widget _buildProjectCardBody(
BuildContext context,
ProjectViewModel viewModel,
theme_vm.ThemeViewModel themeViewModel,
TagViewModel tagViewModel,
Project currentProject,
) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
foregroundDecoration: currentProject.isArchived
? BoxDecoration(
color: Colors.grey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
)
: null,
decoration: BoxDecoration(
image:
currentProject.backgroundImage.isNotEmpty &&
@@ -895,6 +946,16 @@ class _ProjectListPageState extends State<ProjectListPage> {
),
),
),
// 归档状态打勾标识
if (currentProject.isArchived)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
CupertinoIcons.checkmark_circle_fill,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
// 使用Selector监听单个项目的操作状态
Selector<ProjectViewModel, bool>(
selector:
@@ -1139,10 +1200,7 @@ class _ProjectListPageState extends State<ProjectListPage> {
),
],
),
),
);
},
);
);
}
Widget? _buildFloatingActionButton() {
@@ -1351,6 +1409,21 @@ class _ProjectListPageState extends State<ProjectListPage> {
}
},
),
BottomSheetOption.normal(
title: _hideArchived ? '显示归档项目' : '隐藏归档项目',
icon: Icon(
_hideArchived
? CupertinoIcons.eye
: CupertinoIcons.eye_slash,
color: Theme.of(context).colorScheme.primary,
),
onTap: () {
Navigator.pop(context);
setState(() {
_hideArchived = !_hideArchived;
});
},
),
BottomSheetOption.cancel(
context: context,
onTap: () => Navigator.pop(context),
+80 -27
View File
@@ -448,10 +448,29 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage> {
ProjectViewModel viewModel,
) {
log('Showing project options for: ${project.title} (ID: ${project.id})');
final isArchived = project.isArchived;
BottomSheetWidget.show(
title: '项目管理',
context: context,
options: [
if (isArchived)
BottomSheetOption(
icon: Icon(CupertinoIcons.arrow_counterclockwise_circle),
title: '取消归档',
onTap: () {
Navigator.pop(context);
viewModel.unarchiveProject(project);
},
)
else
BottomSheetOption(
icon: Icon(CupertinoIcons.archivebox),
title: '归档',
onTap: () {
Navigator.pop(context);
viewModel.archiveProject(project);
},
),
BottomSheetOption(
icon: Icon(CupertinoIcons.square_on_square),
title: '复制',
@@ -468,28 +487,30 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage> {
_navigateToProjectExport(context, project);
},
),
BottomSheetOption(
icon: Icon(CupertinoIcons.pencil_circle),
title: '编辑',
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => EditProjectPage(project: project),
),
);
},
),
BottomSheetOption.delete(
context: context,
icon: Icon(CupertinoIcons.delete),
title: '删除',
onTap: () {
Navigator.pop(context);
_showDeleteDialog(context, project, viewModel);
},
),
if (!isArchived) ...[
BottomSheetOption(
icon: Icon(CupertinoIcons.pencil_circle),
title: '编辑',
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => EditProjectPage(project: project),
),
);
},
),
BottomSheetOption.delete(
context: context,
icon: Icon(CupertinoIcons.delete),
title: '删除',
onTap: () {
Navigator.pop(context);
_showDeleteDialog(context, project, viewModel);
},
),
],
BottomSheetOption.cancel(
context: context,
onTap: () => Navigator.pop(context),
@@ -637,9 +658,34 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage> {
return GestureDetector(
onTap: () => _navigateToTimeline(context, currentProject),
child: Container(
child: _buildProjectCardBody(
context,
viewModel,
themeViewModel,
tagViewModel,
currentProject,
),
);
},
);
}
Widget _buildProjectCardBody(
BuildContext context,
ProjectViewModel viewModel,
theme_vm.ThemeViewModel themeViewModel,
TagViewModel tagViewModel,
Project currentProject,
) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
foregroundDecoration: currentProject.isArchived
? BoxDecoration(
color: Colors.grey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
)
: null,
decoration: BoxDecoration(
image:
currentProject.backgroundImage.isNotEmpty &&
@@ -679,6 +725,16 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage> {
),
),
),
// 归档状态打勾标识
if (currentProject.isArchived)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
CupertinoIcons.checkmark_circle_fill,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
// 使用Selector监听单个项目的操作状态
Selector<ProjectViewModel, bool>(
selector:
@@ -923,10 +979,7 @@ class _ProjectOverviewPageState extends State<ProjectOverviewPage> {
),
],
),
),
);
},
);
);
}
Widget? _buildFloatingActionButton() {
+1 -1
View File
@@ -241,7 +241,7 @@ class _AddRecordPageState extends State<AddRecordPage> {
imagePath: _backgroundImagePath,
onImageChanged: (path) {
setState(() {
_backgroundImagePath = path!;
_backgroundImagePath = path;
});
},
label: '背景图片',
+135 -79
View File
@@ -232,7 +232,9 @@ class _TimelinePageState extends State<TimelinePage> {
? (_searchQuery.isEmpty
? '请输入关键词'
: '未找到匹配的记录\n尝试其他关键词')
: '暂无记录\n点击下方按钮添加第一条记录',
: (widget.project.isArchived
? '暂无记录'
: '暂无记录\n点击下方按钮添加第一条记录'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
@@ -265,6 +267,13 @@ class _TimelinePageState extends State<TimelinePage> {
) {
return Consumer<theme_vm.ThemeViewModel>(
builder: (context, themeViewModel, child) {
final allRecords = viewModel.records;
final TimelineRecord? earliestRecord = allRecords.isNotEmpty
? allRecords.reduce(
(a, b) => a.createdAt.isBefore(b.createdAt) ? a : b,
)
: null;
final gapMode = themeViewModel.timelineGapMode;
return themeViewModel.showTimelineScrollbar
? Scrollbar(
controller: _scrollController,
@@ -282,7 +291,13 @@ class _TimelinePageState extends State<TimelinePage> {
itemBuilder: (context, index) {
return _isSearchMode
? _buildSearchRecordItem(records[index])
: _buildTimelineItem(context, index, viewModel);
: _buildTimelineItem(
context,
index,
viewModel,
earliestRecord,
gapMode,
);
},
),
)
@@ -296,7 +311,13 @@ class _TimelinePageState extends State<TimelinePage> {
itemBuilder: (context, index) {
return _isSearchMode
? _buildSearchRecordItem(records[index])
: _buildTimelineItem(context, index, viewModel);
: _buildTimelineItem(
context,
index,
viewModel,
earliestRecord,
gapMode,
);
},
);
},
@@ -329,6 +350,8 @@ class _TimelinePageState extends State<TimelinePage> {
BuildContext context,
int index,
TimelineViewModel viewModel,
TimelineRecord? earliestRecord,
theme_vm.GapMode gapMode,
) {
int currentIndex = 0;
@@ -346,12 +369,36 @@ class _TimelinePageState extends State<TimelinePage> {
if (!isFirst) {
if (currentIndex == index) {
final previousRecord = viewModel.records[recordIndex - 1];
final daysBetween = TimeUtils.getTimeBetween(
final prevGap = TimeUtils.getTimeBetween(
previousRecord.createdAt,
record.createdAt,
);
return _buildGapDays(daysBetween);
// 当该记录为项目最早记录时,first/both 模式下不渲染间隔(first gap 为零无意义)
final isEarliest =
earliestRecord != null &&
record.createdAt == earliestRecord.createdAt;
if (isEarliest && gapMode != theme_vm.GapMode.previous) {
return SizedBox.shrink();
}
switch (gapMode) {
case theme_vm.GapMode.previous:
return _buildGapDays(prevGap);
case theme_vm.GapMode.first:
return _buildGapDays(
TimeUtils.getTimeBetween(
earliestRecord!.createdAt,
record.createdAt,
),
);
case theme_vm.GapMode.both:
final firstGap = TimeUtils.getTimeBetween(
earliestRecord!.createdAt,
record.createdAt,
);
return _buildGapDays(prevGap, firstGap);
}
}
if (!isFirst) currentIndex++;
}
@@ -402,6 +449,7 @@ class _TimelinePageState extends State<TimelinePage> {
}
Widget _buildAddRecordButton() {
if (widget.project.isArchived) return SizedBox.shrink();
return AddButtonWidget(
text: '添加记录',
onTap: () {
@@ -421,8 +469,8 @@ class _TimelinePageState extends State<TimelinePage> {
);
}
Widget _buildGapDays(String timeBetween) {
return GapDaysWidget(timeBetween: timeBetween);
Widget _buildGapDays(String timeBetween, [String? secondLine]) {
return GapDaysWidget(timeBetween: timeBetween, secondLine: secondLine);
}
/// 优化的记录项目(使用细粒度更新)
@@ -447,9 +495,11 @@ class _TimelinePageState extends State<TimelinePage> {
log('Opening record detail page for record: ${record.id}');
_navigateToRecordDetail(context, record);
},
onVoiceTap: () {
_showVoiceCommentSheet(context, record);
},
onVoiceTap: widget.project.isArchived
? null
: () {
_showVoiceCommentSheet(context, record);
},
onMoreTap: () {
_showRecordOptionsMenu(context, record, viewModel);
},
@@ -472,35 +522,12 @@ class _TimelinePageState extends State<TimelinePage> {
TimelineViewModel viewModel,
) {
log('Showing record options for: ${record.title} (ID: ${record.id})');
final isArchived = widget.project.isArchived;
BottomSheetWidget.show(
context: context,
title: '记录管理',
backgroundColor: Theme.of(context).colorScheme.onPrimary,
options: [
BottomSheetOption.normal(
title: '编辑记录',
icon: Icon(
CupertinoIcons.pencil_circle,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
log('Opening edit record page for record: ${record.id}');
Navigator.pop(context);
_navigateToEditRecord(context, record);
},
),
BottomSheetOption.normal(
title: '添加评论',
icon: Icon(
CupertinoIcons.ellipses_bubble,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
log('Opening add comment page for record: ${record.id}');
Navigator.pop(context);
_navigateToAddComment(context, record);
},
),
BottomSheetOption.normal(
title: '分享记录',
icon: Icon(
@@ -513,19 +540,6 @@ class _TimelinePageState extends State<TimelinePage> {
_navigateToRecordExport(context, record);
},
),
BottomSheetOption.delete(
context: context,
title: '删除记录',
icon: Icon(
CupertinoIcons.delete,
color: Theme.of(context).colorScheme.error,
),
onTap: () {
log('Opening delete confirmation for record: ${record.id}');
Navigator.pop(context);
_showDeleteConfirmDialog(context, record, viewModel);
},
),
BottomSheetOption.normal(
title: '复制',
icon: Icon(
@@ -537,18 +551,57 @@ class _TimelinePageState extends State<TimelinePage> {
_copyRecordContent(record);
},
),
BottomSheetOption.normal(
title: '更换项目',
icon: Icon(
CupertinoIcons.folder_circle,
color: Theme.of(context).colorScheme.onSurface,
if (!isArchived) ...[
BottomSheetOption.normal(
title: '编辑记录',
icon: Icon(
CupertinoIcons.pencil_circle,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
log('Opening edit record page for record: ${record.id}');
Navigator.pop(context);
_navigateToEditRecord(context, record);
},
),
onTap: () {
log('Opening project selector for record: ${record.id}');
Navigator.pop(context);
_showProjectSelector(context, record);
},
),
BottomSheetOption.normal(
title: '添加评论',
icon: Icon(
CupertinoIcons.ellipses_bubble,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
log('Opening add comment page for record: ${record.id}');
Navigator.pop(context);
_navigateToAddComment(context, record);
},
),
BottomSheetOption.delete(
context: context,
title: '删除记录',
icon: Icon(
CupertinoIcons.delete,
color: Theme.of(context).colorScheme.error,
),
onTap: () {
log('Opening delete confirmation for record: ${record.id}');
Navigator.pop(context);
_showDeleteConfirmDialog(context, record, viewModel);
},
),
BottomSheetOption.normal(
title: '更换项目',
icon: Icon(
CupertinoIcons.folder_circle,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
log('Opening project selector for record: ${record.id}');
Navigator.pop(context);
_showProjectSelector(context, record);
},
),
],
BottomSheetOption.cancel(
context: context,
onTap: () => Navigator.pop(context),
@@ -597,6 +650,7 @@ class _TimelinePageState extends State<TimelinePage> {
Comment comment,
) {
log('Showing comment options for: ${comment.id}');
final isArchived = widget.project.isArchived;
BottomSheetWidget.show(
context: context,
title: '评论管理',
@@ -613,29 +667,31 @@ class _TimelinePageState extends State<TimelinePage> {
_copyCommentContent(comment.content);
},
),
BottomSheetOption.normal(
title: '编辑',
icon: Icon(
CupertinoIcons.pencil_circle,
color: Theme.of(context).colorScheme.onSurface,
if (!isArchived) ...[
BottomSheetOption.normal(
title: '编辑',
icon: Icon(
CupertinoIcons.pencil_circle,
color: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
Navigator.pop(context);
_navigateToEditComment(context, record, comment);
},
),
onTap: () {
Navigator.pop(context);
_navigateToEditComment(context, record, comment);
},
),
BottomSheetOption.delete(
context: context,
title: '删除评论',
icon: Icon(
CupertinoIcons.delete,
color: Theme.of(context).colorScheme.error,
BottomSheetOption.delete(
context: context,
title: '删除评论',
icon: Icon(
CupertinoIcons.delete,
color: Theme.of(context).colorScheme.error,
),
onTap: () {
Navigator.pop(context);
_showDeleteCommentConfirmDialog(context, comment);
},
),
onTap: () {
Navigator.pop(context);
_showDeleteCommentConfirmDialog(context, comment);
},
),
],
BottomSheetOption.cancel(
context: context,
onTap: () => Navigator.pop(context),
+13 -3
View File
@@ -333,7 +333,8 @@ class RecordItemWidget extends StatelessWidget {
if (showTags) Spacer(),
if (onVoiceTap != null)
IconButtonWidget(
icon: CupertinoIcons.mic_circle,
icon: HugeIcons.strokeRoundedMic02,
size: 21,
onTap: onVoiceTap,
),
if (onVoiceTap != null && onMoreTap != null)
@@ -672,8 +673,9 @@ class CommentWidget extends StatelessWidget {
/// 时间间隔组件
class GapDaysWidget extends StatelessWidget {
final String timeBetween;
final String? secondLine;
const GapDaysWidget({super.key, required this.timeBetween});
const GapDaysWidget({super.key, required this.timeBetween, this.secondLine});
@override
Widget build(BuildContext context) {
@@ -715,7 +717,15 @@ class GapDaysWidget extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.only(left: 1.0, right: 1.0),
child: Text(timeBetween),
child: secondLine == null
? Text(timeBetween)
: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'$timeBetween / $secondLine',
style: TextStyle(fontSize: 11),
),
),
),
),
);
+13 -2
View File
@@ -77,8 +77,8 @@ class PrivacyPolicyPage extends StatelessWidget {
<body>
<div class="update-info">
<div>更新日期:<strong>2025/10/11</strong></div>
<div>生效日期:<strong>2025/10/11</strong></div>
<div>更新日期:<strong>2026/07/14</strong></div>
<div>生效日期:<strong>2026/07/14</strong></div>
</div>
<h2>引言</h2>
@@ -117,6 +117,17 @@ class PrivacyPolicyPage extends StatelessWidget {
<strong>请您知悉,上述所有信息均在您的本地设备上处理,不会上传至我们的服务器,我们不将这些信息作为您的“个人信息”进行收集和存储。</strong>
</p>
<h2>应用更新检测</h2>
<p>
为方便您获取新版本,<i>Enil</i> 提供应用内更新检测功能。该功能默认在应用启动时自动执行一次(您可以在“设置-应用更新”中关闭),也可以由您在设置中手动触发。
</p>
<p>
执行更新检测时,应用会向我们所托管更新清单的公开地址(Gitea 仓库的 <i>update.json</i>)发起一次 HTTPS GET 请求,以获取最新版本信息。<strong>该请求不会携带您的任何个人数据或本地数据</strong>,仅用于比对版本号。当检测到新版本时,在 Android 上您可以下载对应的安装包,应用会在下载后通过 SHA256 校验其完整性,再调用系统安装器完成安装;在 iOS 上仅展示版本信息与更新说明,不支持应用内安装。
</p>
<p>
您可以随时在“设置-应用更新”中关闭启动时自动检查。关闭后,除您主动手动触发外,应用不会发起任何更新检测相关的网络请求。
</p>
<h2>二、我们如何使用 Cookie 和同类技术</h2>
<p>
本软件作为一款单机应用,<strong>不会使用任何Cookie、Beacon、SDK、网页信标或类似的追踪技术</strong>。我们不通过这些技术来收集您的信息。
+135 -2
View File
@@ -7,8 +7,10 @@ import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:package_info_plus/package_info_plus.dart';
// import 'notification_settings_page.dart';
import 'theme_settings_page.dart';
import 'update_check_dialog.dart';
// import 'language_settings_page.dart';
import 'clear_images_page.dart';
import 'privacy_policy_page.dart';
@@ -17,6 +19,7 @@ import 'user_profile_page.dart';
import '../../services/backup_service.dart';
import '../../services/database_service.dart';
import '../../services/user_profile_service.dart';
import '../../services/update_check_service.dart';
import '../../models/user_profile.dart';
import '../../viewmodels/theme_viewmodel.dart' as theme_vm;
import '../../viewmodels/project_viewmodel.dart';
@@ -33,11 +36,14 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
UserProfile? _userProfile;
String _appVersion = '';
bool _autoCheckEnabled = true;
@override
void initState() {
super.initState();
_loadUserProfile();
_loadAppInfo();
}
Future<void> _loadUserProfile() async {
@@ -49,6 +55,17 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _loadAppInfo() async {
final info = await PackageInfo.fromPlatform();
final autoCheck = await UpdateCheckPrefs.autoCheckEnabled();
if (mounted) {
setState(() {
_appVersion = info.version;
_autoCheckEnabled = autoCheck;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<theme_vm.ThemeViewModel>(
@@ -150,6 +167,33 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
const SizedBox(height: 20),
_buildSettingsSection(
context: context,
title: '应用更新',
items: [
_buildSwitchItem(
context: context,
icon: CupertinoIcons.arrow_2_circlepath,
title: '启动时自动检查更新',
subtitle: _autoCheckEnabled ? '已开启' : '已关闭',
value: _autoCheckEnabled,
onChanged: (value) async {
await UpdateCheckPrefs.setAutoCheckEnabled(value);
setState(() {
_autoCheckEnabled = value;
});
},
),
_buildSettingsItem(
context: context,
icon: CupertinoIcons.arrow_down_circle,
title: '检查更新',
subtitle: '手动检测是否有新版本',
onTap: () => _checkForUpdates(context),
),
],
),
const SizedBox(height: 20),
_buildSettingsSection(
context: context,
title: '关于',
@@ -158,7 +202,7 @@ class _SettingsPageState extends State<SettingsPage> {
context: context,
icon: CupertinoIcons.info_circle,
title: '应用信息',
subtitle: '版本 1.0.0',
subtitle: '版本 $_appVersion',
onTap: () => _showAboutDialog(context),
),
_buildSettingsItem(
@@ -286,6 +330,95 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
Widget _buildSwitchItem({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
icon,
size: 22,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeTrackColor: Theme.of(context).colorScheme.primary,
activeThumbColor: Theme.of(context).colorScheme.onPrimary,
),
],
),
),
);
}
void _checkForUpdates(BuildContext context) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CupertinoActivityIndicator(),
const SizedBox(height: 16),
Text(
'正在检查更新...',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
);
try {
final result = await UpdateCheckService().checkForUpdates();
if (context.mounted) Navigator.pop(context);
if (!context.mounted) return;
await UpdateCheckDialog.showResult(context, result);
} catch (e) {
if (context.mounted) Navigator.pop(context);
if (!context.mounted) return;
await UpdateCheckDialog.showError(context, e);
}
}
void _navigateToUserProfile(BuildContext context) async {
log('Navigating to user profile page');
await Navigator.push(
@@ -1010,7 +1143,7 @@ class _SettingsPageState extends State<SettingsPage> {
showAboutDialog(
context: context,
applicationName: 'Enil',
applicationVersion: '1.0.0',
applicationVersion: _appVersion.isEmpty ? null : _appVersion,
applicationLegalese: '© 2025 Enil App',
children: [
const SizedBox(height: 16),
@@ -33,6 +33,27 @@ class _ThemeSettingsPageState extends State<ThemeSettingsPage> {
},
];
final List<Map<String, dynamic>> _gapModeOptions = [
{
'value': theme_vm.GapMode.previous,
'title': '距离上一条',
'subtitle': '显示与上一条记录的时间间隔',
'icon': CupertinoIcons.backward,
},
{
'value': theme_vm.GapMode.first,
'title': '距离第一条',
'subtitle': '显示与项目最早记录的时间间隔',
'icon': CupertinoIcons.flag,
},
{
'value': theme_vm.GapMode.both,
'title': '两者都显示',
'subtitle': '同时显示两种间隔,相同时合并为一行',
'icon': CupertinoIcons.list_bullet,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -71,6 +92,8 @@ class _ThemeSettingsPageState extends State<ThemeSettingsPage> {
SizedBox(height: 20),
_buildScrollbarSection(themeViewModel),
SizedBox(height: 20),
_buildGapModeSection(themeViewModel),
SizedBox(height: 20),
_buildBackgroundImageSection(themeViewModel),
SizedBox(height: 20),
_buildCommentBackgroundSection(themeViewModel),
@@ -1132,6 +1155,74 @@ class _ThemeSettingsPageState extends State<ThemeSettingsPage> {
);
}
Widget _buildGapModeSection(theme_vm.ThemeViewModel themeViewModel) {
return _buildSettingsSection(
title: '时间间隔显示',
items:
_gapModeOptions
.map(
(option) => _buildGapModeOption(option, themeViewModel),
)
.toList(),
);
}
Widget _buildGapModeOption(
Map<String, dynamic> option,
theme_vm.ThemeViewModel themeViewModel,
) {
final isSelected = themeViewModel.timelineGapMode == option['value'];
return InkWell(
onTap: () {
themeViewModel.setTimelineGapMode(option['value']);
},
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(
option['icon'],
size: 22,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option['title'],
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 2),
Text(
option['subtitle'],
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
if (isSelected)
Icon(
CupertinoIcons.checkmark_circle_fill,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
);
}
Widget _buildColorThemeEntrySection(theme_vm.ThemeViewModel themeViewModel) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../../services/update_check_service.dart';
/// Dialogs for the update-check feature: manual-check result, update-available
/// detail (with Android install / iOS explicit notice), errors, and download
/// progress. All failures surface explicitly; nothing is silently swallowed.
class UpdateCheckDialog {
/// Shown after a manual check: up-to-date message or update-available detail.
static Future<void> showResult(
BuildContext context,
UpdateCheckResult result,
) async {
if (result.updateAvailable) {
await showUpdateAvailable(context, result);
} else {
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('已是最新版本'),
content: Text(
'当前版本 ${result.installedVersion}(${result.installedVersionCode})已是最新。',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
}
/// Update-available detail: version, release notes, and either an Android
/// "download and install" action or an explicit iOS no-install notice.
static Future<void> showUpdateAvailable(
BuildContext context,
UpdateCheckResult result,
) async {
final service = UpdateCheckService();
final manifest = result.manifest;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('发现新版本 ${manifest.latestVersion}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (manifest.publishDate != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'发布日期: ${manifest.publishDate}',
style: TextStyle(
fontSize: 12,
color: Theme.of(ctx).hintColor,
),
),
),
Text(manifest.releaseNotes),
if (!service.supportsInAppInstall) ...[
const SizedBox(height: 12),
Text(
'iOS 暂不支持应用内安装,请通过其他方式更新。',
style: TextStyle(
fontSize: 12,
color: Theme.of(ctx).colorScheme.error,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('稍后再说'),
),
if (service.supportsInAppInstall)
TextButton(
onPressed: () {
Navigator.pop(ctx);
_downloadAndInstall(context, manifest);
},
child: const Text('下载并安装'),
),
],
),
);
}
/// Explicit error dialog for any update-check failure.
static Future<void> showError(BuildContext context, Object error) async {
final message = error is UpdateCheckException
? error.message
: error.toString();
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('检查更新失败'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
/// Downloads the matching-ABI APK with a progress dialog, verifies SHA256,
/// and triggers the OS installer. Surfaces every failure explicitly.
static Future<void> _downloadAndInstall(
BuildContext context,
UpdateManifest manifest,
) async {
final service = UpdateCheckService();
final scaffoldMessenger = ScaffoldMessenger.of(context);
UpdateAsset asset;
try {
asset = await service.selectAssetForDevice(manifest);
} catch (e) {
if (!context.mounted) return;
await showError(context, e);
return;
}
final progressNotifier = ValueNotifier<double?>(null);
final statusNotifier = ValueNotifier<String>('准备下载...');
if (!context.mounted) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
content: ValueListenableBuilder<String>(
valueListenable: statusNotifier,
builder: (_, status, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ValueListenableBuilder<double?>(
valueListenable: progressNotifier,
builder: (_, progress, _) {
if (progress == null) {
return const CircularProgressIndicator();
}
return LinearProgressIndicator(value: progress);
},
),
const SizedBox(height: 16),
Text(status),
],
),
),
),
);
try {
await service.downloadAndInstall(
asset,
onProgress: (received, total) {
if (total > 0) {
progressNotifier.value = received / total;
statusNotifier.value =
'正在下载... ${(received / 1024 / 1024).toStringAsFixed(1)}'
'/${(total / 1024 / 1024).toStringAsFixed(1)} MB';
}
},
);
statusNotifier.value = '校验完成,已唤起系统安装器';
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('已唤起系统安装器,请在系统界面完成安装')),
);
} catch (e) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
await showError(context, e);
}
} finally {
progressNotifier.dispose();
statusNotifier.dispose();
}
}
}
@@ -154,6 +154,15 @@ class _StatisticsPageState extends State<StatisticsPage> {
),
],
),
if (projectViewModel.archivedCount > 0) ...[
const SizedBox(height: 12),
_buildInfoItem(
icon: CupertinoIcons.archivebox,
title: '已归档项目',
subtitle: '${projectViewModel.archivedCount} 个',
trailing: '',
),
],
],
);
},
+1 -1
View File
@@ -42,7 +42,7 @@ class _ProjectSelectorWidgetState extends State<ProjectSelectorWidget> {
Widget build(BuildContext context) {
return Consumer<ProjectViewModel>(
builder: (context, projectViewModel, child) {
final projects = projectViewModel.projects;
final projects = projectViewModel.projects.where((p) => !p.isArchived).toList();
final isLoading = projectViewModel.isLoading;
return Dialog(
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,110 @@
## Context
Projects are stored in the `projects` SQLite table (schema v11) with no archived concept. The schema is migrated via `_onUpgrade` with additive `ALTER TABLE` steps gated by version (the v8 `background_image` addition is the template). `ProjectViewModel` owns the in-memory `_projects` list, sorting, caching, and `getActiveProjects()`. `ProjectListPage` renders cards with a title row (title + `···` options button) and opens `TimelinePage` on tap. `TimelinePage` exposes several mutation entry points: an "add record" button, a record options bottom sheet (edit/add comment/delete/copy/share/change project), a comment options sheet (copy/edit/delete), and a voice-comment recording sheet. The project selector widget is reused by the "change project" flow.
Key constraints:
- Archived projects must remain visible and historically complete; archiving is not deletion.
- The archived state is reversible; users must be able to unarchive.
- Read-only enforcement must cover every mutation entry point in `TimelinePage`, or the "not editable" guarantee is hollow.
- Migration must be non-destructive and preserve all existing projects and records.
Stakeholders:
- End users who finish or pause projects and want to declutter without data loss.
- Engineering team owning SQLite migration safety and timeline mutation gating.
## Goals / Non-Goals
**Goals:**
- Persist a reversible archived flag per project.
- Mark archived projects visually (checkmark) and block all editing.
- Allow read-only timeline browsing of archived projects.
- Keep archived projects out of selection and active statistics while retaining them in listings, totals, and exports.
**Non-Goals:**
- A separate "archived only" filter view (archived items stay in the main list, visually distinguished).
- Bulk archive operations in this phase.
- Archiving individual records (only project-level archiving).
- Auto-archiving by inactivity rules.
## Decisions
### Decision 1: Persist `isArchived` as an additive integer column, schema v11 -> v12
Rationale:
- Matches the established migration pattern (v8 added `background_image` the same way).
- `INTEGER NOT NULL DEFAULT 0` keeps existing rows unarchived with no data rewrite.
Alternatives considered:
- A separate `archived_projects` table.
- Rejected because it duplicates project schema, complicates queries/sorting/search, and raises sync risk.
- A soft-delete-style timestamp column.
- Rejected; a boolean matches the binary state and is simpler to read/render.
### Decision 2: Use a checkmark (✓) indicator plus grayscale treatment in the card title row, right side
Rationale:
- Product direction explicitly chose a checkmark to signal "archived / concluded".
- Placing it in the title row (left of the `···` button) is consistent with the existing title-row layout and requires no new layout zones.
- A `Container.foregroundDecoration` with a semi-transparent gray overlay (`Colors.grey.withValues(alpha: 0.35)` + matching `borderRadius`) on archived cards provides a clear at-a-glance visual distinction. The foreground layer paints on top of the card content (title, background image, tags, etc.) while keeping the surrounding transparent areas unaffected.
- A toggle in the project page management menu lets users hide archived projects entirely when they want a clean active view.
Alternatives considered:
- A lock or archive-box icon.
- Rejected by product decision in favor of the checkmark.
- A full-card grayscale/dimming treatment.
- Rejected as heavier than needed; the checkmark plus read-only gating is sufficient signal.
### Decision 3: Archived projects open read-only; gate every `TimelinePage` mutation entry point
Rationale:
- "Not editable" must be enforced at every mutation surface, not just the card tap.
- Gating is driven by `project.isArchived` carried into `TimelinePage`.
Mutation entry points to disable when archived:
- Hide the "add record" button.
- Record options sheet: keep copy/share, remove edit/add comment/delete/change project.
- Comment options sheet: keep copy, remove edit/delete.
- Disable voice-comment recording entry.
Alternatives considered:
- Block opening the timeline entirely (card not tappable).
- Rejected; users confirmed they want to browse archived history read-only.
### Decision 4: Archived card's `···` menu reduces to {unarchive, copy, share project}
Rationale:
- Editing is blocked, so edit/delete are removed.
- Unarchive must be reachable (otherwise archiving is irreversible), and it belongs on the item it affects.
Alternatives considered:
- A separate management screen for unarchiving.
- Rejected as disproportionate for a single reversible toggle.
### Decision 5: Exclude archived from selection and active stats; retain in list, total, and exports
Rationale:
- The change-project selector must not offer read-only targets, so archived projects are filtered out.
- `getActiveProjects()` already applies activity heuristics; archived projects are definitionally inactive and must be excluded.
- Total project count retains archived items (they still exist) with an "archived: N" annotation for transparency.
- Export is a read operation and archiving is not deletion, so exports remain available.
Alternatives considered:
- Excluding archived from the main list into a separate section.
- Rejected per non-goal (no separate filtered view this phase).
## Risks / Trade-offs
- [Missed mutation entry point leaves an editing hole] -> Mitigation: enumerate and gate each entry point explicitly; verify by attempting each action on an archived project. Note: `ProjectListPaperlikePage` and `ProjectOptionsBottomSheet`/`MagazineProjectOptionsBottomSheet` are currently disabled/unused (commented out in routing); if re-enabled, they need the same archived gating.
- [Statistics drift if archived exclusion is inconsistent] -> Mitigation: centralize the archived filter in `ProjectViewModel` so list/selector/stats share one source of truth.
- [Checkmark misread as "completed/done"] -> Mitigation: pair with read-only behavior so the meaning is reinforced by interaction; consider a future tooltip/label if users report confusion.
## Migration Plan
1. Bump schema version to 12; add `is_archived INTEGER NOT NULL DEFAULT 0` to `projects` in both `_onCreate` and `_onUpgrade` (gated by `oldVersion < 12`).
2. Extend `Project` model (`isArchived` field, `toMap`/`fromMap`/`copyWith`).
3. Add `archiveProject`/`unarchiveProject` (or reuse `updateProject`) in `ProjectViewModel`; ensure `getActiveProjects()` and selectors exclude archived items; expose an archived count.
4. Render the checkmark and reduced options menu on archived cards in `ProjectListPage`.
5. Gate `TimelinePage` mutation entry points on `project.isArchived`.
6. Filter the project selector widget to exclude archived projects.
7. Update statistics to exclude archived from active count and annotate total with archived count.
8. Rollback strategy: the column defaults to `0`, so reverting code restores today's behavior; the column itself is harmless if left in place.
## Open Questions
- Should archived projects still appear in search results (recommended: yes, but read-only), or be filtered out of search?
- Should the archived count annotation appear only on the statistics page or also as a small badge near the project list header?
@@ -0,0 +1,35 @@
## Why
Today the only end-state for a finished or dormant project is deletion, which destroys its full record history. Users want to shelve completed projects so they stop cluttering active work but remain browsable. Archiving introduces a reversible, read-only shelf state that preserves history without forcing a destructive choice.
## What Changes
- Add an `isArchived` flag to projects via an additive SQLite migration (schema v11 -> v12) and a matching model field.
- Add archive/unarchive actions in the project options menu.
- Render archived project cards with a checkmark indicator in the top-right; archived projects are not editable.
- Allow archived projects to be opened read-only: the timeline is viewable but all mutation entry points (add/edit/delete record, add/edit/delete comment, change project, voice recording) are disabled.
- Reduce the archived card's options menu to {unarchive, copy, share project}.
- Exclude archived projects from the record "change project" selector and from active-project statistics; retain them in the project list and total project count (annotated with the archived count), and keep them exportable.
## Capabilities
### New Capabilities
- `project-archiving`: Users can archive and unarchive projects; archived projects are read-only, visually marked, excluded from selection and active statistics, but remain listed, counted, and exportable.
### Modified Capabilities
- None.
## Impact
- Affected code:
- `DatabaseService` (schema bump to 12, additive `ALTER TABLE projects ADD COLUMN is_archived`, `onCreate`/`onUpgrade` updates).
- `Project` model (`toMap`/`fromMap`/`copyWith` for `isArchived`).
- `ProjectViewModel` (archive/unarchive operations, active-projects filtering, archived count).
- `ProjectListPage` card rendering (checkmark, reduced options menu).
- `TimelinePage` (read-only gating of mutation entry points when `project.isArchived`).
- Project selector widget (exclude archived).
- Statistics page (active vs total project counts).
- Dependencies/systems:
- No new dependencies.
- Migration:
- Additive, non-destructive column with default `0` (not archived), mirroring the v8 `background_image` migration. Existing users see no behavior change until they archive something.
@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Projects SHALL support a persisted archived state
The system MUST store an `isArchived` flag per project in the local database and MUST migrate existing databases non-destructively so all current projects start unarchived.
#### Scenario: Existing projects remain unarchived after upgrade
- **WHEN** a user upgrades from schema v11 to v12
- **THEN** every existing project has `isArchived` set to false and behaves exactly as before
#### Scenario: Archive flag survives relaunch
- **WHEN** a user archives a project and reopens the app
- **THEN** the project is still archived
### Requirement: Archived projects SHALL be visually marked and not editable
The system MUST render a checkmark indicator on archived project cards, MUST apply a grayscale (desaturated) visual treatment to the card, and MUST remove edit/delete actions from their options menu.
#### Scenario: Archived card shows a checkmark and grayscale
- **WHEN** a project is archived
- **THEN** its card displays a checkmark in the title-row right area and the entire card is rendered in grayscale (desaturated)
#### Scenario: Archived card options menu is reduced
- **WHEN** a user opens the options menu of an archived project
- **THEN** only unarchive, copy, and share project are offered; edit and delete are absent
### Requirement: Archived projects SHALL be openable read-only
The system MUST allow users to open an archived project's timeline for browsing, and MUST disable every mutation entry point while the project is archived.
#### Scenario: Add record is unavailable on an archived project
- **WHEN** a user opens an archived project's timeline
- **THEN** the add-record entry is not presented
#### Scenario: Record and comment editing are blocked on an archived project
- **WHEN** a user opens record or comment options on an archived project's timeline
- **THEN** edit, add comment, delete, change project, and voice recording are not offered; copy and share remain available
### Requirement: Archiving SHALL be reversible
The system MUST provide an unarchive action that restores full editing capability.
#### Scenario: Unarchiving restores editing
- **WHEN** a user unarchives a project
- **THEN** the checkmark is removed and all timeline mutation entry points become available again
### Requirement: The change-project selector SHALL exclude archived projects
The system MUST NOT offer archived projects as targets when moving a record between projects.
#### Scenario: Archived project absent from change-project selector
- **WHEN** a user opens the change-project selector
- **THEN** archived projects do not appear among the selectable targets
### Requirement: Users SHALL be able to hide archived projects from the project list
The system MUST provide a toggle in the project page management menu to hide or show archived projects. When hidden, archived projects MUST be excluded from the main project list.
#### Scenario: Hiding archived projects
- **WHEN** a user selects "隐藏归档项目" in the project page management menu
- **THEN** archived projects are excluded from the main project list until the user selects "显示归档项目"
#### Scenario: Showing archived projects again
- **WHEN** a user selects "显示归档项目"
- **THEN** archived projects reappear in the main project list with their grayscale and checkmark treatment
### Requirement: Archived projects SHALL be excluded from active statistics but retained in totals and exports
The system MUST exclude archived projects from active-project statistics, MUST retain them in the total project count (annotated with the archived count), and MUST keep them exportable.
#### Scenario: Archived projects do not count as active
- **WHEN** the system computes active-project statistics
- **THEN** archived projects are excluded from the active count
#### Scenario: Archived projects remain in totals and exportable
- **WHEN** a user views total project count or exports an archived project/timeline
- **THEN** archived projects are included in the total and the export succeeds as a read operation
@@ -0,0 +1,43 @@
## 1. Data Model and Persistence
- [x] 1.1 Bump schema version to 12 and add `is_archived INTEGER NOT NULL DEFAULT 0` to `projects` in `_onCreate`.
- [x] 1.2 Add the additive `ALTER TABLE projects ADD COLUMN is_archived ...` step in `_onUpgrade`, gated by `oldVersion < 12`.
- [x] 1.3 Extend `Project` model with `isArchived` (field, `toMap`, `fromMap`, `copyWith`).
- [x] 1.4 Verify upgrade from v11 preserves all projects as unarchived with no data loss. (automated in `test/project_archiving_test.dart`; DB tests share the project's FFI environment requirement)
## 2. ViewModel Operations
- [x] 2.1 Add `archiveProject`/`unarchiveProject` (or route through `updateProject`) in `ProjectViewModel` with incremental list update.
- [x] 2.2 Ensure `getActiveProjects()` excludes archived projects.
- [x] 2.3 Expose an archived-project count for statistics/list annotation.
- [x] 2.4 Keep search behavior consistent with the read-only intent (archived items searchable but read-only).
## 3. Project List Card
- [x] 3.1 Render a checkmark indicator in the title-row right area when `project.isArchived`.
- [x] 3.2 Apply a grayscale (desaturated) visual treatment to archived project cards.
- [x] 3.3 Reduce the archived card's options menu to {unarchive, copy, share project}.
- [x] 3.4 Confirm archived cards remain tappable to open the timeline read-only.
- [x] 3.5 Add a hide/show archived projects toggle in the project page management menu.
## 4. Read-Only Timeline Gating
- [x] 4.1 Carry `project.isArchived` into `TimelinePage` and gate all mutation entry points.
- [x] 4.2 Hide the "add record" button when archived.
- [x] 4.3 In the record options sheet, remove edit/add comment/delete/change project; keep copy and share.
- [x] 4.4 In the comment options sheet, remove edit/delete; keep copy.
- [x] 4.5 Disable the voice-comment recording entry when archived.
- [ ] 4.6 Verify all gated actions re-enable after unarchiving.
## 5. Selector and Statistics
- [x] 5.1 Filter archived projects out of the change-project selector widget.
- [x] 5.2 Exclude archived projects from active-project statistics.
- [x] 5.3 Retain archived projects in total project count and annotate with "archived: N".
- [x] 5.4 Confirm archived projects remain exportable (project/timeline/record export).
## 6. Verification
- [ ] 6.1 End-to-end: archive -> browse read-only -> unarchive -> edit works again.
- [x] 6.2 Confirm default state for existing users is fully unarchived after upgrade.
- [ ] 6.3 Confirm statistics, selector, and export behave per spec across archived/unarchived states.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,134 @@
## Context
The remote is a self-hosted Gitea instance at `https://git.ooopos.site/agnoy/line.git` (not GitHub), and releases are split-per-ABI APKs (see `CHANGELOG.md` v1.0.1 asset table with SHA256). The app already depends on `dio` and is otherwise local-first (SQLite + SharedPreferences). The settings page and about dialog hardcode `"1.0.0"` while `pubspec.yaml` is at `1.0.1+2`, so any version comparison must first read the real build version. There is no existing update-check code. iOS is not yet shipped, so download/install is Android-focused, but the code must still build and run on iOS.
Key constraints:
- The manifest must be platform-agnostic (hosted as a raw file, not via a Gitea-specific API) so the source can move without rewriting the client.
- Version comparison must be reliable across naming differences; the integer `versionCode` (the `+N` part) is monotonic and authoritative.
- Per the project's no-silent-fallback policy, network/parse/permission failures must surface explicitly; iOS download unavailability must be an explicit notice, not a hidden no-op.
- No forced updates in this phase.
- The auto-check makes a network call on launch in a local-first app, so it must be togglable and disclosed in the privacy policy.
Stakeholders:
- End users who need to learn about and install new versions.
- Release process owner who maintains the manifest per release.
- Engineering team owning network, permissions, and installer integration.
## Goals / Non-Goals
**Goals:**
- Detect new versions from a remote JSON manifest using authoritative `versionCode` comparison.
- Inform users (auto once-per-version notice + manual check) without forced interruption.
- Download, verify, and install the correct ABI APK on Android.
- Show real build version everywhere it is displayed.
- Surface all failures explicitly; degrade iOS download to an explicit notice.
**Non-Goals:**
- Forced/minimum-required update enforcement (the manifest may carry `minRequiredVersionCode` for future use, but it is not enforced this phase).
- Delta/patch updates or a built-in updater that bypasses the OS installer.
- Background scheduled checks beyond the launch hook.
- Self-hosted CDN or backend API; the manifest is a static repo file.
## Decisions
### Decision 1: Hand-maintained JSON manifest served via Gitea raw URL
Rationale:
- Platform-agnostic: Gitea, GitHub, and Gitee all serve raw files, so the source can move without rewriting the client.
- Full control over schema, notes, and asset metadata; no API auth coupling.
Manifest schema:
```json
{
"latestVersion": "1.0.2",
"versionCode": 3,
"minRequiredVersionCode": 2,
"releaseNotes": "1. ...\n2. ...",
"publishDate": "2026-07-14",
"assets": [
{ "abi": "arm64-v8a", "url": "...", "sha256": "...", "size": 22560105 }
]
}
```
Alternatives considered:
- Calling the Gitea REST releases API.
- Rejected: binds the client to Gitea, requires public repo or token handling, and offers less control over notes formatting.
### Decision 2: Compare by integer `versionCode`, read real version via `package_info_plus`
Rationale:
- `versionCode` is monotonic and unambiguous; string version comparison is error-prone.
- `package_info_plus` returns the true build values, replacing the hardcoded `"1.0.0"`.
Alternatives considered:
- Comparing semver strings.
- Rejected: fragile across pre-release/build metadata and locale formatting.
### Decision 3: Use `open_filex` to trigger the Android system installer, with `REQUEST_INSTALL_PACKAGES`
Rationale:
- `open_filex` invokes the OS installer for the downloaded APK; there is no in-process installer.
- Android 8+ requires `REQUEST_INSTALL_PACKAGES` and the user's unknown-source consent, handled explicitly in-app.
- `crypto` provides SHA256 verification; `device_info_plus` exposes the device's `supportedAbis` for ABI asset selection.
Alternatives considered:
- A platform channel to fire the install intent directly.
- Rejected: re-implements what `open_filex` already provides and adds maintenance.
### Decision 4: Auto-check on launch, default ON, togglable, once-per-version notice
Rationale:
- Product chose default ON so users learn of updates promptly; togglable respects the local-first audience.
- A once-per-version notice (tracked by `lastNotifiedVersionCode` in SharedPreferences) avoids nagging; the notice is non-blocking (not a forced modal).
Alternatives considered:
- Default OFF.
- Rejected by product decision.
- A blocking modal on every launch when an update exists.
- Rejected as disruptive.
### Decision 5: Verify SHA256 before installing; surface mismatches as errors
Rationale:
- The manifest already carries per-asset SHA256 (per `CHANGELOG.md`); verifying guards against corruption and tampering.
- A mismatch is an explicit failure, never a silent skip.
Alternatives considered:
- Skip verification for speed.
- Rejected on integrity grounds.
### Decision 6: iOS shows update info with an explicit no-install notice
Rationale:
- iOS cannot sideload, but the code must run there; hiding the feature silently violates no-silent-fallback.
- The notice is explicit: version + notes + "in-app install not supported on iOS".
Alternatives considered:
- Disabling the entire feature on iOS silently.
- Rejected: silent degradation.
### Decision 7: No forced updates this phase
Rationale:
- Product confirmed no force update. The manifest's `minRequiredVersionCode` is reserved for future use and intentionally not enforced now.
## Risks / Trade-offs
- [Repo not publicly readable -> raw URL 401/404] -> Mitigation: confirm repo visibility before release; surface fetch failure explicitly with the URL for diagnostics.
- [Manifest malformed or fields missing] -> Mitigation: parse defensively and fail explicitly with the field that is missing; never assume a version.
- [Network failure on launch] -> Mitigation: auto-check is best-effort and non-blocking; failures are logged/shown subtly, never crash startup.
- [Wrong ABI selected] -> Mitigation: read device ABIs and match against manifest assets; fail explicitly if no match.
- [Install permission denied] -> Mitigation: guide the user to grant unknown-source install consent and re-trigger; do not claim success.
- [HTTP transport MITM] -> Mitigation: prefer HTTPS raw URL; SHA256 verification additionally guards payload integrity.
## Migration Plan
1. Add `package_info_plus` and `open_filex` to `pubspec.yaml`; add `REQUEST_INSTALL_PACKAGES` to `AndroidManifest.xml`.
2. Create the JSON manifest file in the repo and document its schema + the raw URL in the release process.
3. Implement the update-check service (fetch, parse, compare by `versionCode`, download, SHA256 verify).
4. Wire auto-check into app launch (background, togglable, once-per-version notice) and the manual button into settings.
5. Implement Android install via `open_filex` with explicit permission/consent handling.
6. Implement the iOS explicit no-install notice path.
7. Replace hardcoded version strings in settings/about with `package_info_plus` values.
8. Rollback strategy: the feature is additive; disabling the auto-check toggle or removing the settings entry restores prior behavior with no data impact.
## Open Questions
- Resolved during implementation: the default branch is `main` and the repo is publicly readable (`info/refs` returns 200); the manifest raw URL is `https://git.ooopos.site/agnoy/line/raw/branch/main/update.json` and serves once `update.json` is committed/pushed.
- Resolved: the auto-check is rate-limited to once per 24h (in addition to the once-per-version notice) to reduce network footprint.
- Resolved: release notes are plain text for v1 (`\n`-separated); markdown rendering is deferred.
- Still open: whether to surface a small in-app changelog history (past manifests) beyond the latest version.
@@ -0,0 +1,36 @@
## Why
The app ships as APK releases on a self-hosted Gitea instance, but users have no in-app way to discover new versions. A lightweight JSON manifest in the repo plus an in-app checker closes this loop without introducing a backend. As a prerequisite, the app currently displays hardcoded version strings ("1.0.0") that drift from the real build version, so reliable version comparison first requires reading the actual installed version.
## What Changes
- Maintain a JSON manifest file in the repo describing the latest release: version name, version code, release notes, publish date, optional minimum required code, and per-ABI asset URLs with SHA256 and size.
- Add an update-check service that fetches the manifest over the existing `dio` client and compares the manifest's `versionCode` against the installed build's `versionCode` (read via `package_info_plus`).
- Auto-check on app launch (default ON, togglable in settings) with a non-blocking, once-per-version notice tracked by the last notified `versionCode`.
- Add a manual "check for updates" action in settings that fetches on demand and shows a result dialog (latest / new version with notes).
- On Android: download the asset matching the device ABI, verify its SHA256 against the manifest, and trigger the system installer via `open_filex` (with `REQUEST_INSTALL_PACKAGES` permission and the Android 8+ unknown-source consent flow).
- On iOS: surface the new version and release notes with an explicit "in-app install not supported on iOS" notice (no silent degradation).
- Replace the hardcoded "1.0.0" version strings in the settings page and about dialog with values from `package_info_plus`.
## Capabilities
### New Capabilities
- `app-update-check`: The app can detect, inform, and (on Android) install new versions from a remote JSON manifest, with explicit failure surfacing, a togglable auto-check, and no forced updates.
### Modified Capabilities
- None.
## Impact
- Affected code:
- New update-check service (manifest fetch, version comparison, download, SHA256 verify).
- Settings page (manual check button, auto-check toggle, real version display) and about dialog (real version).
- App startup hook (background auto-check) and preferences (auto-check toggle, last notified code).
- `pubspec.yaml` (add `package_info_plus`, `open_filex`).
- `android/app/src/main/AndroidManifest.xml` (`REQUEST_INSTALL_PACKAGES`).
- Dependencies/systems:
- New: `package_info_plus`, `open_filex`, `crypto` (SHA256), `device_info_plus` (device ABI).
- Existing: `dio` (already present).
- New repo artifact: the JSON manifest file served via Gitea raw URL.
- Privacy/security:
- The app is local-first; the auto-check makes one outbound GET on launch. Default ON per product decision, togglable, and documented in the privacy policy. No user data is sent.
@@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: The app SHALL display and compare its real build version
The system MUST read the installed build's version name and version code from the platform (not hardcoded constants) and MUST use the integer version code as the authoritative comparison key.
#### Scenario: Settings and about show the real version
- **WHEN** a user opens the settings page or about dialog
- **THEN** the displayed version matches the build's actual version name and code from `package_info_plus`
#### Scenario: Version comparison uses version code
- **WHEN** the system decides whether an update is available
- **THEN** it compares the manifest's `versionCode` against the installed `versionCode` as integers
### Requirement: The system SHALL fetch a remote JSON manifest to detect updates
The system MUST fetch the update manifest over the network, parse the latest version metadata, and determine availability; fetch and parse failures MUST surface explicitly rather than being swallowed.
#### Scenario: Newer version detected
- **WHEN** the manifest's `versionCode` is greater than the installed `versionCode`
- **THEN** the system reports that an update is available with its version and notes
#### Scenario: Already up to date
- **WHEN** the manifest's `versionCode` is less than or equal to the installed `versionCode`
- **THEN** the system reports the app is up to date
#### Scenario: Manifest fetch or parse fails
- **WHEN** the network request fails or the manifest is malformed
- **THEN** the system surfaces an explicit error and MUST NOT silently report "up to date"
### Requirement: The system SHALL auto-check for updates on launch, togglable and non-blocking
The system MUST perform a background update check on app launch by default, MUST allow the user to disable it in settings, and MUST notify at most once per new version via a non-blocking notice.
#### Scenario: Auto-check is on by default
- **WHEN** a user installs the app and launches it
- **THEN** the background auto-check runs without the user configuring anything
#### Scenario: Once-per-version notice
- **WHEN** an update is detected and the user has already been notified of that `versionCode`
- **THEN** the notice is not shown again until a newer `versionCode` appears
#### Scenario: Auto-check can be disabled
- **WHEN** a user disables auto-check in settings
- **THEN** no background network check runs on subsequent launches
### Requirement: The system SHALL provide a manual check-for-updates action
The system MUST offer a user-initiated check that fetches on demand and shows the result in a dialog.
#### Scenario: Manual check shows result
- **WHEN** a user taps the check-for-updates action
- **THEN** the system fetches the manifest and displays either the new version with notes or an up-to-date message
### Requirement: On Android, the system SHALL download, verify, and install the matching ABI APK
The system MUST select the asset matching the device ABI, download it, verify its SHA256 against the manifest, and trigger the OS installer; SHA256 mismatch, ABI mismatch, download failure, and permission denial MUST each surface as explicit errors.
#### Scenario: Successful download and install
- **WHEN** a user on Android chooses to install an available update and grants install consent
- **THEN** the system downloads the matching ABI APK, verifies SHA256, and opens the OS installer
#### Scenario: SHA256 mismatch blocks install
- **WHEN** a downloaded APK's SHA256 does not match the manifest
- **THEN** the system reports the integrity failure and MUST NOT trigger the installer
#### Scenario: Install consent denied
- **WHEN** a user denies unknown-source install consent
- **THEN** the system reports that installation cannot proceed and MUST NOT claim success
### Requirement: On iOS, the system SHALL surface update info with an explicit no-install notice
The system MUST show the new version and release notes on iOS and MUST explicitly state that in-app installation is not supported, rather than silently doing nothing.
#### Scenario: iOS shows update info without install
- **WHEN** an update is detected on iOS
- **THEN** the system displays the version and notes plus an explicit notice that in-app install is not supported on iOS
### Requirement: The system SHALL NOT force updates
The system MUST NOT block app usage based on the installed version; update prompts MUST always be dismissible.
#### Scenario: Update prompt is always dismissible
- **WHEN** an update is available
- **THEN** the user can dismiss the notice and continue using the app unrestricted
@@ -0,0 +1,49 @@
## 1. Dependencies and Platform Config
- [x] 1.1 Add `package_info_plus` and `open_filex` to `pubspec.yaml` and run `flutter pub get`. (also added `crypto` for SHA256 and `device_info_plus` for ABI detection)
- [x] 1.2 Add `REQUEST_INSTALL_PACKAGES` permission to `AndroidManifest.xml`.
- [x] 1.3 Confirm the Gitea repo is publicly readable and determine the raw URL branch (`main`/`master`). (repo `info/refs` returns 200; default branch is `main`; `update.json` will serve once committed/pushed)
## 2. Remote Manifest
- [x] 2.1 Create the JSON manifest file in the repo with the agreed schema (latestVersion, versionCode, minRequiredVersionCode, releaseNotes, publishDate, assets[abi,url,sha256,size]).
- [x] 2.2 Document the manifest schema, the raw URL, and the per-release update steps in the release process (README or release notes). (`docs/update-check-manifest.md`)
## 3. Update-Check Service
- [x] 3.1 Implement manifest fetch over `dio` with explicit error surfacing on network/HTTP failure.
- [x] 3.2 Implement parsing with explicit failure on missing/invalid fields (never assume a version).
- [x] 3.3 Implement version comparison using integer `versionCode` read from `package_info_plus`.
- [x] 3.4 Implement ABI asset selection against the device's supported ABIs, failing explicitly on no match.
## 4. Download, Verify, Install (Android)
- [x] 4.1 Download the selected APK to the app cache/temp directory.
- [x] 4.2 Compute the downloaded file's SHA256 and compare to the manifest; block install on mismatch with an explicit error.
- [x] 4.3 Trigger the OS installer via `open_filex`.
- [x] 4.4 Handle Android 8+ unknown-source consent flow explicitly; report denial without claiming success.
## 5. Auto-Check and Manual Check
- [x] 5.1 Wire a background auto-check into app launch (non-blocking, best-effort, does not crash startup on failure).
- [x] 5.2 Add an auto-check toggle in settings (default ON), persisted in SharedPreferences.
- [x] 5.3 Track `lastNotifiedVersionCode` and show the notice at most once per new version. (also 24h rate limit on the check itself)
- [x] 5.4 Add a manual "check for updates" action in settings with a result dialog (new version + notes / up to date / error).
## 6. iOS Path and No Forced Updates
- [x] 6.1 On iOS, surface version + notes with an explicit "in-app install not supported on iOS" notice.
- [x] 6.2 Ensure all update notices/prompts are dismissible and never block app usage.
## 7. Real Version Display
- [x] 7.1 Replace hardcoded `"1.0.0"` in the settings page subtitle with `package_info_plus` values.
- [x] 7.2 Replace hardcoded `"1.0.0"` in the about dialog with `package_info_plus` values.
- [x] 7.3 Update the privacy policy to disclose the launch network check.
## 8. Verification
- [ ] 8.1 Confirm detection works for newer, equal, and older manifest version codes.
- [ ] 8.2 Confirm explicit errors for fetch failure, malformed manifest, ABI mismatch, SHA256 mismatch, and permission denial.
- [ ] 8.3 Confirm once-per-version notice does not repeat and auto-check toggle disables the launch check.
- [ ] 8.4 Confirm iOS path shows info + explicit no-install notice without crashing.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,89 @@
## Context
The timeline renders a `GapDaysWidget` between consecutive records. Today the only value it shows is `TimeUtils.getTimeBetween(previousRecord.createdAt, record.createdAt)` computed inside `TimelinePage._buildTimelineItem`. The `_calculateItemCount` helper already reserves one slot per inter-record gap. The app stores display preferences in `SharedPreferences` through `ThemeViewModel` (e.g. `simplifyTimelineContent`, `useCircularEmojiStyle`), each following the same getter/setter/persist/`notifyListeners` pattern. The theme settings page hosts the matching toggle UI.
The background-image bug lives in `AddRecordPage`, where the `SingleImagePicker` callback is written as `_backgroundImagePath = path!`. `SingleImagePicker`'s "remove" button calls `onImageChanged(null)`, so `path!` throws `Null check operator used on a null value` before `setState` runs, leaving the stale image in place. `EditRecordPage` already handles this correctly with `_backgroundImagePath = path ?? ''`, confirming the intended contract.
Key constraints:
- The earliest record is a per-project invariant and must not depend on whether the user toggles ascending/descending sort.
- `both` mode must not widen the existing gap pill (it sits on the timeline's vertical line at `screenWidth * 0.2`), or it will distort the timeline layout.
- The bugfix must align `AddRecordPage` with `EditRecordPage`; no new behavior is introduced.
Stakeholders:
- End users who track long-running projects and want to perceive elapsed time at a glance.
- Engineering team maintaining timeline rendering and the preferences layer.
## Goals / Non-Goals
**Goals:**
- Let users pick among three gap-display modes from settings.
- Compute the "first" gap against the project's chronologically earliest record regardless of sort order.
- Keep `both` mode within the current pill footprint via a two-line layout and collapse duplicate values.
- Fix the add-record background-image removal so it behaves like the edit-record flow.
**Non-Goals:**
- Per-project gap-mode overrides (global setting only).
- Changing `TimeUtils.getTimeBetween` output formatting.
- Redesigning the timeline vertical line or gap pill visual identity.
- Touching project or record background-image behavior beyond the removal bugfix.
## Decisions
### Decision 1: Model gap mode as an enum persisted through `ThemeViewModel`
Rationale:
- Matches the established preference pattern (`simplifyTimelineContent`, etc.), so persistence, defaults, and UI wiring are uniform.
- An enum (`GapMode { previous, first, both }`) is clearer than parallel booleans and makes a future fourth option additive.
Alternatives considered:
- Two independent booleans ("show previous", "show first").
- Rejected because it produces an invalid "neither" state and complicates the settings UI.
### Decision 2: Define "first record" as the chronologically earliest record in the project
Rationale:
- "Distance from the first record" semantically means "how long has this project been running", which is a fixed anchor, not a function of display order.
- Computing it as `records.reduce(min by createdAt)` (or a precomputed earliest) keeps the value stable when the user toggles sort.
- For the earliest record itself, the first gap would be zero (rendered by `TimeUtils` as "1分"), which is meaningless, so in `first`/`both` modes the gap widget before the earliest record is skipped (`SizedBox.shrink`). `previous` mode is unaffected and keeps its existing gap for every non-first-displayed record, preserving default behavior.
Alternatives considered:
- Using `records.first` (the list's first item).
- Rejected because in descending sort `records.first` is the newest record, which contradicts the agreed meaning.
### Decision 3: Render `both` mode as a single line separated by ` / `, never collapsing duplicates
Rationale:
- A single-line `prevGap / firstGap` keeps the pill width within the existing `screenWidth * 0.2` and preserves the timeline line alignment, using `FittedBox(scaleDown)` to fit longer strings.
- Always showing both values (even when equal, e.g. `3天 / 3天`) gives a consistent, predictable display; collapsing adds conditional complexity and surprises users who expect a stable format.
Alternatives considered:
- A vertical two-line stack.
- Rejected because it makes the pill taller and pushes the timeline line; the single-line format is visually cleaner.
- Collapsing to one value when equal.
- Rejected per user feedback: equal values should still be shown for consistency.
### Decision 4: Fix the background-image bug by matching `EditRecordPage`'s null-coalescing contract
Rationale:
- `SingleImagePicker.onImageChanged` is typed `String?` and deliberately passes `null` on remove.
- `_backgroundImagePath = path` (the field is already `String?`) restores the intended behavior with no new logic, mirroring `EditRecordPage`'s `path ?? ''`.
Alternatives considered:
- Changing `SingleImagePicker` to pass an empty string instead of null.
- Rejected because the edit page already depends on the null contract; changing the widget would force an audit of all callers.
## Risks / Trade-offs
- [Earliest-record lookup cost in long timelines] -> Mitigation: compute the earliest record once per timeline load (or per build via a cheap reduce over already-loaded records), not per gap.
- [Two-line pill taller than the line slot] -> Mitigation: keep the pill vertically centered in the existing 80px-tall slot and cap font size; verify on small screens.
- [Settings menu growth] -> Mitigation: group the new selector under the existing timeline-display section alongside `simplifyTimelineContent` to avoid a sprawling settings list.
## Migration Plan
1. Add `GapMode` enum, `_timelineGapMode` field, persistence key `timeline_gap_mode` (default `previous`), getter/setter to `ThemeViewModel`.
2. Add a three-option selector in the theme settings page.
3. Update `TimelinePage._buildTimelineItem` to compute both `prevGap` and `firstGap` (earliest-record based) and select value(s) by mode.
4. Update `GapDaysWidget` to accept one or two values and render two lines in `both` mode, collapsing duplicates.
5. Fix `AddRecordPage` background-image callback (`path!` -> `path`).
6. Rollback strategy: the preference defaults to `previous`, so disabling the feature restores today's rendering; the bugfix is independently safe to revert.
## Open Questions
- Should the gap-mode selector live in the theme settings page or a dedicated "timeline display" grouping if one is later introduced?
- Should duplicate collapsing in `both` mode also apply when the two values merely round to the same string (e.g. "1天" vs "1天") even if the underlying durations differ slightly? (Recommended: compare rendered strings, which is what the user sees.)
@@ -0,0 +1,31 @@
## Why
The timeline currently shows only one kind of inter-record gap ("distance from previous record"). Users want richer temporal context — how long since the project's first record, or both distances at once — to feel the arc of a long-running project. Separately, removing a record's background image on the add-record page silently crashes (a null assertion), so once a background is set it can never be cleared from that page.
## What Changes
- Add a global setting with three modes controlling what each inter-record gap displays: `previous`, `first`, or `both`.
- Measure the `first` gap against the chronologically earliest record in the project, independent of the timeline sort order.
- Render `both` mode as a two-line pill, collapsing to a single value when the previous and first gaps are equal (avoids redundant output like "3天 / 3天").
- Fix the background-image removal bug in `AddRecordPage` so tapping "remove" clears the image instead of throwing a null assertion.
## Capabilities
### New Capabilities
- `timeline-gap-display`: Users can choose how the time gap between consecutive timeline records is displayed (previous / first / both), computed against the project's earliest record and rendered with duplicate collapsing.
- `record-background-image`: Users can set and remove an optional background image when adding a record, without the remove action failing.
### Modified Capabilities
- None.
## Impact
- Affected code:
- `ThemeViewModel` (new gap-mode preference and persistence) and the theme settings page (new selector UI).
- `TimelinePage._buildTimelineItem` and `_buildGapDays` (compute both gaps, pass selected value(s)).
- `GapDaysWidget` (two-line layout for `both` mode).
- `AddRecordPage` (fix null assertion in the `SingleImagePicker.onImageChanged` callback).
- Dependencies/systems:
- No new dependencies. Reuses existing `SharedPreferences` preference pattern and `TimeUtils.getTimeBetween`.
- Migration:
- No database migration. New preference defaults to `previous`, preserving current behavior for existing users.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Users SHALL be able to remove a record's background image when adding a record
The system MUST allow the user to clear an already-selected background image on the add-record page, and the remove action MUST NOT throw or silently retain the stale image.
#### Scenario: Removing a set background image clears it
- **WHEN** a user has selected a background image on the add-record page and taps the remove control
- **THEN** the background image field is cleared and the empty-state picker is shown, with no error thrown
#### Scenario: A set background image is retained until explicitly removed
- **WHEN** a user selects a background image and submits the record without removing it
- **THEN** the saved record stores the selected background image path
#### Scenario: Add-record behavior matches edit-record behavior
- **WHEN** a user removes a background image on the add-record page
- **THEN** the resulting state matches the behavior of the edit-record page's background-image removal
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Users SHALL be able to choose how inter-record time gaps are displayed
The system SHALL provide a global setting with three modes — `previous`, `first`, and `both` — controlling what each inter-record gap on the timeline displays, persisted across launches and defaulting to `both`.
#### Scenario: Default mode is `both`
- **WHEN** a user has never configured gap display
- **THEN** the timeline shows both the distance from the previous record and the distance from the earliest record, separated by ` / ` on a single line
#### Scenario: Switching mode updates the timeline
- **WHEN** a user selects a different gap mode in settings and returns to the timeline
- **THEN** every inter-record gap reflects the new mode without restarting the app
### Requirement: The "first" gap SHALL be measured against the project's chronologically earliest record
The system MUST compute the `first` gap as the time between each record and the project's earliest record by `createdAt`, and this value MUST NOT change when the user toggles ascending/descending sort.
#### Scenario: Descending sort still anchors to the earliest record
- **WHEN** a user views a timeline in descending order with mode `first`
- **THEN** each gap shows the distance from that record to the project's earliest record, identical to the value shown in ascending order
#### Scenario: Earliest record has no preceding gap in first/both modes
- **WHEN** a record is the project's earliest record and the gap mode is `first` or `both`
- **THEN** the system renders no gap widget before it, because the first gap would be zero; `previous` mode is unaffected and keeps its existing gap for every non-first-displayed record
### Requirement: "both" mode SHALL render both values on a single line separated by ` / `
The system MUST render `both` mode as a single-line value formatted `prevGap / firstGap` within the existing gap pill footprint, fitting via scale-down when needed. Both values MUST always be shown, even when equal.
#### Scenario: Equal gaps are still shown
- **WHEN** the gap immediately follows the earliest record (so previous and first gaps are identical)
- **THEN** the pill shows `prevGap / firstGap` (e.g. `3天 / 3天`), not a single collapsed value
#### Scenario: Divergent gaps show both values
- **WHEN** a record's previous gap and first gap differ
- **THEN** the pill shows `prevGap / firstGap` on a single line, without widening the timeline layout
@@ -0,0 +1,26 @@
## 1. Gap-Mode Preference
- [x] 1.1 Add `GapMode { previous, first, both }` enum and `_timelineGapMode` field (default `previous`) with getter/setter to `ThemeViewModel`.
- [x] 1.2 Persist and load the mode via `SharedPreferences` key `timeline_gap_mode`, following the existing preference pattern.
- [x] 1.3 Add a three-option selector in the theme settings page, grouped with the existing timeline-display toggles.
## 2. Timeline Gap Rendering
- [x] 2.1 In `TimelinePage`, determine the project's chronologically earliest record once per loaded records list (reduce by `createdAt`).
- [x] 2.2 In `_buildTimelineItem`, compute both `prevGap` (previous record) and `firstGap` (earliest record) for each non-first record.
- [x] 2.3 Select the value(s) to display based on the current `GapMode`, reading the mode from `ThemeViewModel`.
- [x] 2.4 Extend `GapDaysWidget` to accept one or two values; render `both` mode as a two-line stack within the existing pill footprint.
- [x] 2.5 Render `both` mode as `prevGap / firstGap` on a single line; always show both values (no collapsing).
- [x] 2.6 Verify gap values stay identical when toggling ascending/descending sort (for `first`/`both`).
## 3. Background-Image Removal Bugfix
- [x] 3.1 In `AddRecordPage`, change `_backgroundImagePath = path!` to `_backgroundImagePath = path` in the `SingleImagePicker.onImageChanged` callback.
- [x] 3.2 Confirm `EditRecordPage`'s existing `path ?? ''` handling remains consistent with the fix.
- [ ] 3.3 Manually verify set/remove/submit flows on both add and edit pages.
## 4. Verification
- [ ] 4.1 Confirm default mode (`previous`) reproduces today's rendering for existing users.
- [ ] 4.2 Verify `both` mode pill does not widen or shift the timeline vertical line on small screens.
- [ ] 4.3 Verify the remove-background action no longer throws and clears the image on the add-record page.
+83
View File
@@ -0,0 +1,83 @@
## Purpose
Detects, informs, and installs new app versions from a remote JSON manifest with explicit failure surfacing.
## Requirements
### Requirement: The app SHALL display and compare its real build version
The system MUST read the installed build's version name and version code from the platform (not hardcoded constants) and MUST use the integer version code as the authoritative comparison key.
#### Scenario: Settings and about show the real version
- **WHEN** a user opens the settings page or about dialog
- **THEN** the displayed version matches the build's actual version name and code from `package_info_plus`
#### Scenario: Version comparison uses version code
- **WHEN** the system decides whether an update is available
- **THEN** it compares the manifest's `versionCode` against the installed `versionCode` as integers
### Requirement: The system SHALL fetch a remote JSON manifest to detect updates
The system MUST fetch the update manifest over the network, parse the latest version metadata, and determine availability; fetch and parse failures MUST surface explicitly rather than being swallowed.
#### Scenario: Newer version detected
- **WHEN** the manifest's `versionCode` is greater than the installed `versionCode`
- **THEN** the system reports that an update is available with its version and notes
#### Scenario: Already up to date
- **WHEN** the manifest's `versionCode` is less than or equal to the installed `versionCode`
- **THEN** the system reports the app is up to date
#### Scenario: Manifest fetch or parse fails
- **WHEN** the network request fails or the manifest is malformed
- **THEN** the system surfaces an explicit error and MUST NOT silently report "up to date"
### Requirement: The system SHALL auto-check for updates on launch, togglable and non-blocking
The system MUST perform a background update check on app launch by default, MUST allow the user to disable it in settings, and MUST notify at most once per new version via a non-blocking notice.
#### Scenario: Auto-check is on by default
- **WHEN** a user installs the app and launches it
- **THEN** the background auto-check runs without the user configuring anything
#### Scenario: Once-per-version notice
- **WHEN** an update is detected and the user has already been notified of that `versionCode`
- **THEN** the notice is not shown again until a newer `versionCode` appears
#### Scenario: Auto-check can be disabled
- **WHEN** a user disables auto-check in settings
- **THEN** no background network check runs on subsequent launches
### Requirement: The system SHALL provide a manual check-for-updates action
The system MUST offer a user-initiated check that fetches on demand and shows the result in a dialog.
#### Scenario: Manual check shows result
- **WHEN** a user taps the check-for-updates action
- **THEN** the system fetches the manifest and displays either the new version with notes or an up-to-date message
### Requirement: On Android, the system SHALL download, verify, and install the matching ABI APK
The system MUST select the asset matching the device ABI, download it, verify its SHA256 against the manifest, and trigger the OS installer; SHA256 mismatch, ABI mismatch, download failure, and permission denial MUST each surface as explicit errors.
#### Scenario: Successful download and install
- **WHEN** a user on Android chooses to install an available update and grants install consent
- **THEN** the system downloads the matching ABI APK, verifies SHA256, and opens the OS installer
#### Scenario: SHA256 mismatch blocks install
- **WHEN** a downloaded APK's SHA256 does not match the manifest
- **THEN** the system reports the integrity failure and MUST NOT trigger the installer
#### Scenario: Install consent denied
- **WHEN** a user denies unknown-source install consent
- **THEN** the system reports that installation cannot proceed and MUST NOT claim success
### Requirement: On iOS, the system SHALL surface update info with an explicit no-install notice
The system MUST show the new version and release notes on iOS and MUST explicitly state that in-app installation is not supported, rather than silently doing nothing.
#### Scenario: iOS shows update info without install
- **WHEN** an update is detected on iOS
- **THEN** the system displays the version and notes plus an explicit notice that in-app install is not supported on iOS
### Requirement: The system SHALL NOT force updates
The system MUST NOT block app usage based on the installed version; update prompts MUST always be dismissible.
#### Scenario: Update prompt is always dismissible
- **WHEN** an update is available
- **THEN** the user can dismiss the notice and continue using the app unrestricted
+75
View File
@@ -0,0 +1,75 @@
## Purpose
Manages the archived state of projects: read-only browsing, visual marking, statistics exclusion, and hide/show.
## Requirements
### Requirement: Projects SHALL support a persisted archived state
The system MUST store an `isArchived` flag per project in the local database and MUST migrate existing databases non-destructively so all current projects start unarchived.
#### Scenario: Existing projects remain unarchived after upgrade
- **WHEN** a user upgrades from schema v11 to v12
- **THEN** every existing project has `isArchived` set to false and behaves exactly as before
#### Scenario: Archive flag survives relaunch
- **WHEN** a user archives a project and reopens the app
- **THEN** the project is still archived
### Requirement: Archived projects SHALL be visually marked and not editable
The system MUST render a checkmark indicator on archived project cards, MUST apply a grayscale (desaturated) visual treatment to the card, and MUST remove edit/delete actions from their options menu.
#### Scenario: Archived card shows a checkmark and grayscale
- **WHEN** a project is archived
- **THEN** its card displays a checkmark in the title-row right area and the entire card is rendered in grayscale (desaturated)
#### Scenario: Archived card options menu is reduced
- **WHEN** a user opens the options menu of an archived project
- **THEN** only unarchive, copy, and share project are offered; edit and delete are absent
### Requirement: Archived projects SHALL be openable read-only
The system MUST allow users to open an archived project's timeline for browsing, and MUST disable every mutation entry point while the project is archived.
#### Scenario: Add record is unavailable on an archived project
- **WHEN** a user opens an archived project's timeline
- **THEN** the add-record entry is not presented
#### Scenario: Record and comment editing are blocked on an archived project
- **WHEN** a user opens record or comment options on an archived project's timeline
- **THEN** edit, add comment, delete, change project, and voice recording are not offered; copy and share remain available
### Requirement: Archiving SHALL be reversible
The system MUST provide an unarchive action that restores full editing capability.
#### Scenario: Unarchiving restores editing
- **WHEN** a user unarchives a project
- **THEN** the checkmark is removed and all timeline mutation entry points become available again
### Requirement: The change-project selector SHALL exclude archived projects
The system MUST NOT offer archived projects as targets when moving a record between projects.
#### Scenario: Archived project absent from change-project selector
- **WHEN** a user opens the change-project selector
- **THEN** archived projects do not appear among the selectable targets
### Requirement: Users SHALL be able to hide archived projects from the project list
The system MUST provide a toggle in the project page management menu to hide or show archived projects. When hidden, archived projects MUST be excluded from the main project list.
#### Scenario: Hiding archived projects
- **WHEN** a user selects "隐藏归档项目" in the project page management menu
- **THEN** archived projects are excluded from the main project list until the user selects "显示归档项目"
#### Scenario: Showing archived projects again
- **WHEN** a user selects "显示归档项目"
- **THEN** archived projects reappear in the main project list with their grayscale and checkmark treatment
### Requirement: Archived projects SHALL be excluded from active statistics but retained in totals and exports
The system MUST exclude archived projects from active-project statistics, MUST retain them in the total project count (annotated with the archived count), and MUST keep them exportable.
#### Scenario: Archived projects do not count as active
- **WHEN** the system computes active-project statistics
- **THEN** archived projects are excluded from the active count
#### Scenario: Archived projects remain in totals and exportable
- **WHEN** a user views total project count or exports an archived project/timeline
- **THEN** archived projects are included in the total and the export succeeds as a read operation
@@ -0,0 +1,21 @@
## Purpose
Manages optional background images on records, including removal during creation.
## Requirements
### Requirement: Users SHALL be able to remove a record's background image when adding a record
The system MUST allow the user to clear an already-selected background image on the add-record page, and the remove action MUST NOT throw or silently retain the stale image.
#### Scenario: Removing a set background image clears it
- **WHEN** a user has selected a background image on the add-record page and taps the remove control
- **THEN** the background image field is cleared and the empty-state picker is shown, with no error thrown
#### Scenario: A set background image is retained until explicitly removed
- **WHEN** a user selects a background image and submits the record without removing it
- **THEN** the saved record stores the selected background image path
#### Scenario: Add-record behavior matches edit-record behavior
- **WHEN** a user removes a background image on the add-record page
- **THEN** the resulting state matches the behavior of the edit-record page's background-image removal
@@ -0,0 +1,39 @@
## Purpose
Controls how time gaps between consecutive timeline records are displayed.
## Requirements
### Requirement: Users SHALL be able to choose how inter-record time gaps are displayed
The system SHALL provide a global setting with three modes — `previous`, `first`, and `both` — controlling what each inter-record gap on the timeline displays, persisted across launches and defaulting to `both`.
#### Scenario: Default mode is `both`
- **WHEN** a user has never configured gap display
- **THEN** the timeline shows both the distance from the previous record and the distance from the earliest record, separated by ` / ` on a single line
#### Scenario: Switching mode updates the timeline
- **WHEN** a user selects a different gap mode in settings and returns to the timeline
- **THEN** every inter-record gap reflects the new mode without restarting the app
### Requirement: The "first" gap SHALL be measured against the project's chronologically earliest record
The system MUST compute the `first` gap as the time between each record and the project's earliest record by `createdAt`, and this value MUST NOT change when the user toggles ascending/descending sort.
#### Scenario: Descending sort still anchors to the earliest record
- **WHEN** a user views a timeline in descending order with mode `first`
- **THEN** each gap shows the distance from that record to the project's earliest record, identical to the value shown in ascending order
#### Scenario: Earliest record has no preceding gap in first/both modes
- **WHEN** a record is the project's earliest record and the gap mode is `first` or `both`
- **THEN** the system renders no gap widget before it, because the first gap would be zero; `previous` mode is unaffected and keeps its existing gap for every non-first-displayed record
### Requirement: "both" mode SHALL render both values on a single line separated by ` / `
The system MUST render `both` mode as a single-line value formatted `prevGap / firstGap` within the existing gap pill footprint, fitting via scale-down when needed. Both values MUST always be shown, even when equal.
#### Scenario: Equal gaps are still shown
- **WHEN** the gap immediately follows the earliest record (so previous and first gaps are identical)
- **THEN** the pill shows `prevGap / firstGap` (e.g. `3天 / 3天`), not a single collapsed value
#### Scenario: Divergent gaps show both values
- **WHEN** a record's previous gap and first gap differ
- **THEN** the pill shows `prevGap / firstGap` on a single line, without widening the timeline layout
+49 -1
View File
@@ -122,7 +122,7 @@ packages:
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -153,6 +153,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
url: "https://pub.dev"
source: hosted
version: "10.1.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio:
dependency: "direct main"
description:
@@ -493,6 +509,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
open_filex:
dependency: "direct main"
description:
name: open_filex
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -1002,6 +1042,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
url: "https://pub.dev"
source: hosted
version: "1.1.5"
xdg_directories:
dependency: transitive
description:
+5 -1
View File
@@ -1,7 +1,7 @@
name: line
description: "A timeline app for project management and life recording."
publish_to: 'none'
version: 1.0.0+1
version: 1.0.2+3
environment:
sdk: ^3.7.2
@@ -33,6 +33,10 @@ dependencies:
audioplayers: ^6.5.1
record: ^6.1.2
flutter_colorpicker: ^1.1.0
package_info_plus: ^8.0.0
open_filex: ^4.5.0
crypto: ^3.0.6
device_info_plus: ^10.1.0
dev_dependencies:
flutter_test:
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:line/models/project.dart';
import 'package:line/services/database_service.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite/sqflite.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Project model isArchived contract', () {
Project baseProject() => Project(
title: 'P',
description: 'd',
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
test('defaults to not archived', () {
expect(baseProject().isArchived, isFalse);
});
test('toMap writes is_archived as 0/1', () {
expect(baseProject().toMap()['is_archived'], 0);
expect(
baseProject().copyWith(isArchived: true).toMap()['is_archived'],
1,
);
});
test('fromMap reads is_archived and tolerates missing field', () {
final now = DateTime(2026, 1, 1).millisecondsSinceEpoch;
final base = <String, dynamic>{
'id': 1,
'title': 'P',
'description': 'd',
'created_at': now,
'updated_at': now,
};
expect(Project.fromMap({...base, 'is_archived': 1}).isArchived, isTrue);
expect(Project.fromMap({...base, 'is_archived': 0}).isArchived, isFalse);
// Legacy maps without the column must default to not archived.
expect(Project.fromMap(base).isArchived, isFalse);
});
test('copyWith toggles isArchived and preserves it when omitted', () {
final archived = baseProject().copyWith(isArchived: true);
expect(archived.isArchived, isTrue);
expect(archived.copyWith(title: 'X').isArchived, isTrue);
expect(archived.copyWith(isArchived: false).isArchived, isFalse);
});
});
Future<String> newDatabasePath(String name) async {
final basePath = await getDatabasesPath();
return path.join(
basePath,
'${name}_${DateTime.now().microsecondsSinceEpoch}.db',
);
}
Future<void> createLegacyV11Database({required String dbPath}) async {
await deleteDatabase(dbPath);
final db = await openDatabase(
dbPath,
version: 11,
onCreate: (database, _) async {
await database.execute('''
CREATE TABLE projects(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
image_list TEXT,
background_image TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
final now = DateTime.now().millisecondsSinceEpoch;
await database.insert('projects', {
'id': 1,
'title': 'Legacy project',
'description': 'legacy',
'created_at': now,
'updated_at': now,
});
},
);
await db.close();
}
Future<DatabaseService> openService(String dbPath) async {
DatabaseService.debugDatabasePathOverride = dbPath;
final service = DatabaseService();
await service.forceReinitializeDatabase();
return service;
}
test(
'upgrading from v11 adds is_archived column and keeps projects unarchived',
() async {
final dbPath = await newDatabasePath('archiving_upgrade_v11');
await createLegacyV11Database(dbPath: dbPath);
final service = await openService(dbPath);
final db = await service.database;
final columns = await db.rawQuery('PRAGMA table_info(projects)');
final columnNames =
columns.map((column) => column['name'] as String).toList();
expect(columnNames, contains('is_archived'));
final projects = await service.getProjects();
expect(projects, hasLength(1));
expect(projects.first.title, 'Legacy project');
expect(projects.first.isArchived, isFalse);
},
);
test(
'archiving and unarchiving a project persists through the database layer',
() async {
final dbPath = await newDatabasePath('archiving_persist');
final service = await openService(dbPath);
final now = DateTime.now();
final id = await service.insertProject(
Project(
title: 'Archivable project',
description: 'desc',
createdAt: now,
updatedAt: now,
),
);
final before = (await service.getProject(id))!;
expect(before.isArchived, isFalse);
await service.updateProject(before.copyWith(isArchived: true));
final archived = (await service.getProject(id))!;
expect(archived.isArchived, isTrue);
await service.updateProject(archived.copyWith(isArchived: false));
final unarchived = (await service.getProject(id))!;
expect(unarchived.isArchived, isFalse);
},
);
test('a fresh database creates the projects table with is_archived', () async {
final dbPath = await newDatabasePath('archiving_fresh');
final service = await openService(dbPath);
final db = await service.database;
final columns = await db.rawQuery('PRAGMA table_info(projects)');
final columnNames =
columns.map((column) => column['name'] as String).toList();
expect(columnNames, contains('is_archived'));
final now = DateTime.now();
final id = await service.insertProject(
Project(
title: 'Fresh project',
description: 'desc',
createdAt: now,
updatedAt: now,
),
);
final project = (await service.getProject(id))!;
expect(project.isArchived, isFalse);
});
}
+30
View File
@@ -0,0 +1,30 @@
{
"latestVersion": "1.0.2",
"versionCode": 3,
"minRequiredVersionCode": 1,
"releaseNotes": "1. 修复添加记录时无法移除背景图片的问题\n2. 新增时间间隔显示模式(距离上一条 / 距离第一条 / 两者)\n3. 新增项目归档功能(归档后只读浏览,灰色遮罩标识)\n4. 新增应用内更新检测(每 24 小时自动检测,支持应用内安装 APK)\n5. 优化录音按钮图标样式",
"publishDate": "2026-07-15",
"assets": [
{
"abi": "arm64-v8a",
"versionCode": 2003,
"url": "https://git.ooopos.site/agnoy/line/releases/download/v1.0.2/app-arm64-v8a-release.apk",
"sha256": "6c8efa0bae2395647e8d676f76d3bb187183f389f3538b7e0d8f2e6579e7278b",
"size": 22634962
},
{
"abi": "armeabi-v7a",
"versionCode": 1003,
"url": "https://git.ooopos.site/agnoy/line/releases/download/v1.0.2/app-armeabi-v7a-release.apk",
"sha256": "bc5f6254484443207c86a3376ba413fecd2717bcaf1fee09e6a8a3947ee7a227",
"size": 20704616
},
{
"abi": "x86_64",
"versionCode": 4003,
"url": "https://git.ooopos.site/agnoy/line/releases/download/v1.0.2/app-x86_64-release.apk",
"sha256": "1d42e5f06a931ad4fcd2129d287996a63830247bcee0b75f1f6aa85a1d495311",
"size": 23807057
}
]
}