Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f4f0d40a | ||
|
|
212d600ae8 | ||
|
|
d132170310 | ||
|
|
4d66e7ad02 | ||
|
|
2ce1fd8ade | ||
|
|
d821f47522 | ||
|
|
82a7a45052 | ||
|
|
09003c258a | ||
|
|
c06547b41b |
@@ -53,3 +53,5 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
.pi/
|
||||
|
||||
+105
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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".
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}"',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目相关操作
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -241,7 +241,7 @@ class _AddRecordPageState extends State<AddRecordPage> {
|
||||
imagePath: _backgroundImagePath,
|
||||
onImageChanged: (path) {
|
||||
setState(() {
|
||||
_backgroundImagePath = path!;
|
||||
_backgroundImagePath = path;
|
||||
});
|
||||
},
|
||||
label: '背景图片',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>。我们不通过这些技术来收集您的信息。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
+70
@@ -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.
|
||||
+16
@@ -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
|
||||
+34
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user