Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,5 @@ npm-debug.log*
firebase-debug.log*
firestore-debug.log*
serviceAccount.json

.omx/
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
![analyze](https://img.shields.io/badge/flutter%20analyze-passing-success)
![tests](https://img.shields.io/badge/tests-78%20passed-success)
![coverage](https://img.shields.io/badge/coverage-94.5%25-green)
![skill-generator](https://img.shields.io/badge/Skill%20Generator-WarmMemo%20%2B%20Colleague-ffb86b)
![json-import](https://img.shields.io/badge/JSON%20Import-Validated-4caf50)
![copy-friendly](https://img.shields.io/badge/UI-Selectable%20Text-8bc34a)

WarmMemo is a Flutter Web + Firebase app for memorial drafting, obituary generation, package checkout, and admin-side order operations.

Expand All @@ -32,6 +35,10 @@ Target: `v0.2.0` (release candidate)
- Assignment completion rate
- Delivery completion rate
- Weekly funnel trend panel (last 8 weeks) in Admin dashboard
- Final countdown planning upgraded:
- health self-assessment (5 dimensions, current vs target)
- three-axis comparison (health / wealth / lifetime)
- memory experience progress with category distribution

### Production Hardening

Expand All @@ -47,6 +54,9 @@ Target: `v0.2.0` (release candidate)

- Email/password login with role-based access (`user` / `admin`)
- First-time onboarding (3 steps): select service, generate first draft, confirm token balance
- Copy-friendly UI:
- major generated content supports text selection + copy
- final countdown page supports direct selection/copy
- Memorial page:
- public link + QR code generation/download
- proposal submission for tombstone/columbarium purchase workflow
Expand All @@ -55,8 +65,15 @@ Target: `v0.2.0` (release candidate)
- share link + QR + export options
- Final countdown planner:
- asset/cost planning with zero-balance guidance
- target controls (target lifetime / target end-balance)
- memory experience checklist with categories:
`家庭 / 旅行 / 學習 / 貢獻`
- Die with Zero readiness score (composite index)
- Package checkout and order status tracking
- Notification center (unread filter + mark read)
- Digital clone skill generator:
- WarmMemo (daily) / Colleague (work) dual templates
- validated JSON input and one-click JSON file import (web)

### Admin

Expand Down
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ Prompt 模式選擇:
1. `flow.md`:先理解按鈕到程式執行流程
2. `progress.md`:確認目前進度與已知風險
3. `info.md`:再看完整技術架構與契約細節

近期重點(2026-04-09):

- 人生倒數頁新增健康自評、三軸比較、記憶體驗與類別分佈統計
- 共用工具 stub(下載/JSON 匯入)補強並新增測試覆蓋
20 changes: 19 additions & 1 deletion docs/flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,25 @@ AdminDashboard

---

### 3-8. 人生倒數:`現況 vs 目標` + `記憶分佈`

```
FinalCountdownTab.build()
├─ 既有:資產/支出計算、零結餘分數
├─ 新增:健康五面向(現況/目標)計算
├─ 新增:三軸比較(健康/財務/壽命)
├─ 新增:記憶進度(完成度 70% + 滿意度 30%)
├─ 新增:體驗類別分佈(家庭/旅行/學習/貢獻)
└─ SharedPreferences 儲存擴充欄位(向下相容)
```

- 健康自評:五面向 `1-5` 分,顯示現況/目標/差距。
- 目標參數:目標壽命、目標期末結餘。
- 記憶體驗:每筆可設定「類別、是否完成、滿意度」,並顯示分佈比例。
- 綜合指標:`Die with Zero 準備度` 以多軸對齊度加權。

---

## 四、漏斗狀態(業務視角)

`提案送出 -> Admin 審核 -> 供應商指派 -> 材質確認 -> 排程建立/交付`
Expand Down Expand Up @@ -181,4 +200,3 @@ Web 首屏不綁大型中文字型,改系統字型 fallback。
- 使用者可寫:`proposal`
- Admin 可寫:`vendorAssignment`、`materialSelection`、`deliverySchedule`、`vendors`
- 既有付款與訂單規則保持不變

11 changes: 11 additions & 0 deletions docs/info.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ For a human-readable button-by-button flow diagram, see `docs/flow.md`.
- **Name**: WarmMemo
- **Type**: Flutter Web + Firebase application
- **Purpose**: 提供紀念頁、訃聞草稿、商務訂單流程(提案到交付)與 Admin 管理能力
- **Current expansion**: 人生倒數頁已擴充健康自評、三軸目標比較與記憶分佈統計
- **Primary use case**: 家屬與禮儀服務團隊協作,縮短從提案到交付的流程時間
- **Tech stack**: Flutter, Firebase Auth, Cloud Firestore, GitHub Actions (CI/CD)

Expand Down Expand Up @@ -93,6 +94,7 @@ PDF 匯出字型策略(on-demand):
- `lib/features/memorial/memorial_page_tab.dart`
- `lib/features/obituary/digital_obituary_tab.dart`
- `lib/features/admin/admin_dashboard.dart`
- `lib/features/final_countdown/final_countdown_tab.dart`
- `lib/core/export/pdf_exporter.dart`
- `lib/core/export/compliance_exporter.dart`
- `lib/data/services/*`
Expand Down Expand Up @@ -124,3 +126,12 @@ After changing code:
2. Run targeted tests (or full tests for release/demo)
3. Update docs when architecture/flow/progress materially changes

Recent documentation-relevant changes to keep in sync:
- Final countdown now stores extended keys in `final_countdown_tab_v1`:
- `healthCurrent`, `healthTarget`
- `targetEndBalance`, `targetLifeExpectancy`
- `experienceItems` (with `category`, `completed`, `satisfaction`)
- Utility stubs with dedicated tests:
- `lib/core/utils/download_text_stub.dart`
- `lib/core/utils/import_json_stub.dart`
- `test/core_utils_stub_test.dart`
13 changes: 11 additions & 2 deletions docs/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# WarmMemo — 專案進度紀錄

> 最後更新:2026-04-05
> 最後更新:2026-04-09

---

Expand Down Expand Up @@ -55,6 +55,16 @@ WarmMemo 是 Flutter Web + Firebase 應用,目標是把「紀念內容準備 +
- 搜尋友善區塊改為圖文卡片。
- 指定文案對應圖片已替換。

4. **人生倒數頁升級(Die with Zero)**
- 新增健康自評表(五面向,現況/目標)。
- 新增三軸比較(健康/財務/壽命)與綜合「Die with Zero 準備度」。
- 新增記憶體驗清單(完成度/滿意度)與類別分佈(家庭/旅行/學習/貢獻)。

5. **跨平台 stub 與測試覆蓋提升**
- `download_text_stub.dart` 增加可測試注入點與 fallback 路徑。
- `import_json_stub.dart` 明確非 web 平台錯誤語意。
- 新增 `test/core_utils_stub_test.dart`,覆蓋 stub 主要分支。

---

## 已知風險 / 限制
Expand All @@ -69,4 +79,3 @@ WarmMemo 是 Flutter Web + Firebase 應用,目標是把「紀念內容準備 +
- 補入真正的 `assets/fonts/NotoSansTC-Subset.ttf`(可離線穩定匯出中文)。
- 在 CI 增加 coverage artifact 與閾值檢查。
- 增加 release checklist 腳本化(analyze/test/build 一鍵執行)。

71 changes: 71 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,65 @@ service cloud.firestore {
);
}

function cyberSkillVersionValid(v) {
return v is string && v.matches('^v[0-9]{6}$');
}

function cyberSkillSourceStatsValid(summary) {
return !('sourceStats' in summary)
|| (
summary.sourceStats is map
&& summary.sourceStats.keys().hasOnly(['messages', 'documents', 'emails'])
&& (!('messages' in summary.sourceStats) || summary.sourceStats.messages is int)
&& (!('documents' in summary.sourceStats) || summary.sourceStats.documents is int)
&& (!('emails' in summary.sourceStats) || summary.sourceStats.emails is int)
);
}

function cyberSkillSummaryValid(summary) {
return summary is map
&& summary.keys().hasOnly([
'toneTraits',
'decisionPriorities',
'workMethods',
'boundaries',
'sourceStats'
])
&& (!('toneTraits' in summary) || (summary.toneTraits is list && summary.toneTraits.size() <= 10))
&& (!('decisionPriorities' in summary) || (summary.decisionPriorities is list && summary.decisionPriorities.size() <= 10))
&& (!('workMethods' in summary) || (summary.workMethods is list && summary.workMethods.size() <= 10))
&& (!('boundaries' in summary) || (summary.boundaries is list && summary.boundaries.size() <= 10))
&& cyberSkillSourceStatsValid(summary);
}

function cyberSkillCommonValid() {
return request.resource.data.keys().hasOnly([
'templateType',
'profileName',
'profileIdentity',
'analysisSummary',
'markdown',
'version',
'createdAt',
'updatedAt'
])
&& request.resource.data.templateType in ['warmmemoDaily', 'colleagueWork']
&& request.resource.data.profileName is string
&& request.resource.data.profileName.size() > 0
&& request.resource.data.profileName.size() <= 80
&& request.resource.data.profileIdentity is string
&& request.resource.data.profileIdentity.size() <= 160
&& cyberSkillSummaryValid(request.resource.data.analysisSummary)
&& request.resource.data.markdown is string
&& request.resource.data.markdown.size() > 0
&& request.resource.data.markdown.size() <= 20000
&& cyberSkillVersionValid(request.resource.data.version)
&& request.resource.data.createdAt is string
&& request.resource.data.createdAt.size() <= 40
&& request.resource.data.updatedAt is string
&& request.resource.data.updatedAt.size() <= 40;
}

match /users/{userId} {
allow read: if isOwner(userId) || isAdmin();
allow create: if isOwner(userId)
Expand Down Expand Up @@ -161,6 +220,18 @@ service cloud.firestore {
allow update, delete: if isAdmin();
}

match /cyberSkills/{skillId} {
allow read: if isOwner(userId) || isAdmin();
allow create: if (isOwner(userId) || isAdmin())
&& cyberSkillCommonValid()
&& request.resource.data.version == 'v000001';
allow update: if (isOwner(userId) || isAdmin())
&& cyberSkillCommonValid()
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.version > resource.data.version;
allow delete: if isOwner(userId) || isAdmin();
}

match /topupRequests/{requestId} {
allow read: if isOwner(userId) || isAdmin();
allow create: if isOwner(userId)
Expand Down
2 changes: 2 additions & 0 deletions lib/core/layout/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../../features/memorial/memorial_page_tab.dart';
import '../../features/obituary/digital_obituary_tab.dart';
import '../../features/overview/overview_tab.dart';
import '../../features/packages/packages_tab.dart';
import '../../features/skills/skill_generator_tab.dart';

class AppShell extends StatefulWidget {
const AppShell({super.key, this.initialIndex = 0});
Expand All @@ -36,6 +37,7 @@ class _AppShellState extends State<AppShell>
with SingleTickerProviderStateMixin {
final List<_NavItem> _baseDestinations = const [
_NavItem('流程總覽', Icons.map_outlined, OverviewTab()),
_NavItem('數位分身 Skill', Icons.psychology_alt_outlined, SkillGeneratorTab()),
_NavItem('人生倒數', Icons.hourglass_bottom_outlined, FinalCountdownTab()),
_NavItem('固定方案', Icons.handshake_outlined, PackagesTab()),
_NavItem('簡易紀念頁', Icons.person_outline, MemorialPageTab()),
Expand Down
52 changes: 52 additions & 0 deletions lib/core/utils/download_text_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';

typedef ShareXFilesFn = Future<void> Function(List<XFile> files);
typedef ClipboardSetTextFn = Future<void> Function(String text);

Future<void> downloadTextFile({
required String content,
required String filename,
ShareXFilesFn? shareXFiles,
ClipboardSetTextFn? clipboardSetText,
}) async {
final normalized = content.trimRight();
if (normalized.isEmpty) {
throw StateError('download-empty-content');
}

final safeFilename = _sanitizeFilename(filename);
final bytes = Uint8List.fromList(utf8.encode(normalized));
final share =
shareXFiles ??
(List<XFile> files) {
return Share.shareXFiles(files);
};
final setClipboard =
clipboardSetText ??
(String text) {
return Clipboard.setData(ClipboardData(text: text));
};

try {
await share([
XFile.fromData(bytes, mimeType: 'text/markdown', name: safeFilename),
]);
} catch (_) {
await setClipboard(normalized);
}
}

@visibleForTesting
String sanitizeDownloadFilename(String raw) => _sanitizeFilename(raw);

String _sanitizeFilename(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return 'warmmemo_export.md';
final safe = trimmed.replaceAll(RegExp(r'[\\/:*?"<>|]+'), '_');
if (safe.endsWith('.md')) return safe;
return '$safe.md';
}
22 changes: 22 additions & 0 deletions lib/core/utils/download_text_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'dart:js_interop';

import 'package:web/web.dart' as web;

Future<void> downloadTextFile({
required String content,
required String filename,
}) async {
final blob = web.Blob(
[content.toJS].toJS,
web.BlobPropertyBag(type: 'text/markdown;charset=utf-8'),
);
final url = web.URL.createObjectURL(blob);
final anchor = web.HTMLAnchorElement()
..href = url
..download = filename
..style.display = 'none';
web.document.body?.append(anchor);
anchor.click();
anchor.remove();
web.URL.revokeObjectURL(url);
}
3 changes: 3 additions & 0 deletions lib/core/utils/import_json_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Future<String?> pickJsonTextFile() async {
throw UnsupportedError('json-import-not-supported');
}
38 changes: 38 additions & 0 deletions lib/core/utils/import_json_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use

import 'dart:async';
import 'dart:html' as html;

Future<String?> pickJsonTextFile() async {
final input = html.FileUploadInputElement()
..accept = '.json,application/json'
..multiple = false;

final completer = Completer<String?>();

input.onChange.listen((_) {
final file = input.files?.isNotEmpty == true ? input.files!.first : null;
if (file == null) {
if (!completer.isCompleted) completer.complete(null);
return;
}
final reader = html.FileReader();
reader.onLoad.listen((_) {
if (!completer.isCompleted) {
completer.complete(reader.result as String?);
}
});
reader.onError.listen((_) {
if (!completer.isCompleted) {
completer.completeError(StateError('json-file-read-failed'));
}
});
reader.readAsText(file);
});

input.click();
return completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () => null,
);
}
Loading
Loading