diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fda593..f89f7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ ### Fixed (Redmine #342) - **ChatScreen 하단 잘림** — Android edge-to-edge 모드에서 시스템 nav bar (3-button / gesture handle) 가 입력창을 덮던 문제. `Scaffold.body` 를 `SafeArea(top: false, …)` 로 감쌈. AppBar 가 이미 top inset 처리하므로 top 만 false. +### UX round 1 — raw enum 노출 정리 (Redmine #342 추가) +- **습관 카드 부제** — `build · L3 · …` (raw enum) → `만들기 · …`. FrameLevel 노출 제거 (시스템 규약이라 사용자 가치 낮음). +- **스트릭 화면 현재 티어** — `T0` / `T1` raw → `🌱 새싹` / `🥉 3회 도전` / `🥈 7일 형성` / `🥇 30일 정착` / `🏆 6주 완주` 이모지+한국어 라벨. +- **스트릭 강등 경고** — `Never miss twice 발동 — 티어 강등` (영문 잠언) → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.`. +- **스트릭 hero 위계** — 현재 스트릭을 `displayLarge` 큰 숫자 + 티어 라벨로 시각 강조 (사용자의 핵심 동기 지표). +- **습관 추가 드롭다운** — `만들기 (build)` → `만들기` (영어 식별자 병기 제거). +- 신규 `app/lib/ui/labels.dart` — domain enum 의 한국어 라벨 매핑 단일 지점. domain layer 에 `koreanLabel` 두지 않음 (관심사 분리). + ### Dev - **LLM 실패 빨간 배너에 full message + stack trace** — 단말 진단을 위해 release 빌드에서도 노출. `LLM 응답 실패: \n\n--- STACK ---\n` 형식. SelectableText + monospace + 최대 화면 1/3 높이 + scroll. 사용자 친화 메시지로 좁히는 작업은 #342 종료 후 follow-up. diff --git a/app/lib/ui/labels.dart b/app/lib/ui/labels.dart new file mode 100644 index 0000000..62b42bf --- /dev/null +++ b/app/lib/ui/labels.dart @@ -0,0 +1,44 @@ +import '../domain/models/habit.dart'; +import '../domain/streak/compute_streak.dart'; + +/// UI 한국어 라벨 매핑. domain enum 의 `dbValue` 는 DB 직렬화용이므로 +/// 사용자에게 그대로 노출하면 'build', 'L3', 'T0' 같은 raw 식별자가 +/// 그대로 보인다. 본 헬퍼는 그걸 한국어 표현으로 바꾼다. + +String habitTypeLabel(HabitType t) { + switch (t) { + case HabitType.build: + return '만들기'; + case HabitType.breakHabit: + return '없애기'; + } +} + +/// Drift row (raw db String) 에서 직접 매핑. 'build' / 'break' 외의 값은 +/// 그대로 노출해 invariant 위반을 가시화. +String habitTypeLabelFromDb(String dbValue) { + switch (dbValue) { + case 'build': + return '만들기'; + case 'break': + return '없애기'; + default: + return dbValue; + } +} + +/// 5-Tier Reward Ladder (T0 새싹 → T4 6주 완주). milestone 누적 보상. +String rewardTierLabel(RewardTier t) { + switch (t) { + case RewardTier.t0: + return '🌱 새싹'; + case RewardTier.t1: + return '🥉 3회 도전'; + case RewardTier.t2: + return '🥈 7일 형성'; + case RewardTier.t3: + return '🥇 30일 정착'; + case RewardTier.t4: + return '🏆 6주 완주'; + } +} diff --git a/app/lib/ui/screens/habit_create_screen.dart b/app/lib/ui/screens/habit_create_screen.dart index d50d6cb..bbaaea5 100644 --- a/app/lib/ui/screens/habit_create_screen.dart +++ b/app/lib/ui/screens/habit_create_screen.dart @@ -94,9 +94,8 @@ class _HabitCreateScreenState extends ConsumerState { DropdownButtonFormField( initialValue: _type, items: const [ - DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')), - DropdownMenuItem( - value: HabitType.breakHabit, child: Text('없애기 (break)')), + DropdownMenuItem(value: HabitType.build, child: Text('만들기')), + DropdownMenuItem(value: HabitType.breakHabit, child: Text('없애기')), ], onChanged: (v) => setState(() => _type = v ?? HabitType.build), decoration: const InputDecoration(labelText: '타입'), diff --git a/app/lib/ui/screens/habit_list_screen.dart b/app/lib/ui/screens/habit_list_screen.dart index 8d39496..5eb0e19 100644 --- a/app/lib/ui/screens/habit_list_screen.dart +++ b/app/lib/ui/screens/habit_list_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../state/ai_providers.dart'; import '../../state/providers.dart'; +import '../labels.dart'; import 'chat_screen.dart'; import 'check_in_screen.dart'; import 'habit_create_screen.dart'; @@ -85,8 +86,10 @@ class HabitListScreen extends ConsumerWidget { final h = habits[i]; return ListTile( title: Text(h.title), + // FrameLevel (L2/L3) 은 시스템 규약이라 사용자에게 노출 + // 가치 낮음 — type chip + framedText 만 표시. subtitle: Text( - '${h.type} · ${h.frameLevel} · ${h.frameFramedText}', + '${habitTypeLabelFromDb(h.type)} · ${h.frameFramedText}', maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/app/lib/ui/screens/streak_screen.dart b/app/lib/ui/screens/streak_screen.dart index f0d5619..fd5729f 100644 --- a/app/lib/ui/screens/streak_screen.dart +++ b/app/lib/ui/screens/streak_screen.dart @@ -5,6 +5,7 @@ import '../../core/time.dart'; import '../../domain/models/tracker_entry.dart'; import '../../domain/streak/compute_streak.dart'; import '../../state/providers.dart'; +import '../labels.dart'; class StreakScreen extends ConsumerWidget { final String habitId; @@ -51,25 +52,43 @@ class StreakScreen extends ConsumerWidget { asOf: nowKst(), habitStartedAt: habit.startedAt as String, ); + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(habit.title as String, - style: Theme.of(context).textTheme.titleLarge), + style: theme.textTheme.titleLarge), const SizedBox(height: 24), - _Row('현재 스트릭', '${state.currentStreak}일'), + // Hero — 핵심 동기 지표. 큰 숫자 + 티어 emoji 라벨로 위계 강조. + Center( + child: Column( + children: [ + Text( + '${state.currentStreak}', + style: theme.textTheme.displayLarge?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + Text('일 연속 (현재 스트릭)', + style: theme.textTheme.bodyMedium), + const SizedBox(height: 4), + Text(rewardTierLabel(state.currentTier), + style: theme.textTheme.titleMedium), + ], + ), + ), + const Divider(height: 40), _Row('최장 스트릭', '${state.longestStreak}일'), _Row('최근 30일 / 완료', '${state.doneCountInWindow30}회'), _Row('Phase 42일 / 완료', '${state.doneCountInPhase42}회'), - const Divider(height: 32), - _Row('현재 티어', state.currentTier.dbValue), if (state.neverMissTwiceBroken) const Padding( padding: EdgeInsets.only(top: 12), child: Text( - '⚠ Never miss twice 발동 — 티어 강등', + '⚠ 이틀 연속 빠졌어요. 한 단계 강등됐습니다.', style: TextStyle(color: Colors.redAccent), ), ), diff --git a/docs/design/342-v042-hotfix/README.md b/docs/design/342-v042-hotfix/README.md new file mode 100644 index 0000000..1360bf4 --- /dev/null +++ b/docs/design/342-v042-hotfix/README.md @@ -0,0 +1,114 @@ +# 설계서: v0.4.2 hotfix — ChatScreen SafeArea + LLM 진단 + UX round 1 (#342) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #342 · 관련 ADR: 없음 +> · 구현 파일: `app/lib/ui/screens/chat_screen.dart`, `app/lib/state/chat_providers.dart`, `app/lib/ui/labels.dart`, `app/lib/ui/screens/habit_list_screen.dart`, `app/lib/ui/screens/streak_screen.dart`, `app/lib/ui/screens/habit_create_screen.dart` +> · 테스트: 기존 167 회귀 (신규 추가 없음 — string label / SafeArea wrap 라 단위 가치 낮음) + +## 1. 목적 (Why) +v0.4.1 실 단말 (Android, 사용자 본인) 첫 테스트에서 발견된 사용성·진단 격차 묶음. 모두 dev 단계 신속 hotfix. + +1. **A — ChatScreen 입력창 가림**: edge-to-edge 모드의 시스템 nav bar 가 send 버튼/입력창을 덮어 사용 불가. +2. **B — LLM 실패 원인 불명**: warm-up 은 Ready 까지 가지만 send 실패 시 빨간 배너에 `LLM 응답 실패: ` 만 떠 원인 진단 불가. +3. **C — UX round 1 (raw enum 노출)**: 습관 카드/스트릭/추가 화면이 Drift row 의 `'build'` / `RewardTier.dbValue('T0')` / `'Never miss twice'` 같은 식별자를 그대로 사용자에 노출. + +## 2. 범위 (Scope) +- **포함**: + - A. `ChatScreen` Scaffold.body → `SafeArea(top: false, …)`. + - B. `userTurn` catch 가 `e.toString() + stack` 전체를 error state 에 저장. ChatScreen 빨간 배너를 `SingleChildScrollView + SelectableText` (monospace, 12pt, 최대 1/3 높이) 로 교체. + - C. `app/lib/ui/labels.dart` 신규 — `habitTypeLabel(HabitType)`, `habitTypeLabelFromDb(String)`, `rewardTierLabel(RewardTier)`. P0 3건 + P1 2건. +- **제외 (out of scope)**: + - LLM `BackendInitException: model may be invalid` 실 원인 (GPU KV cache RESOURCE_EXHAUSTED 후보) — 단말 빌드/설치 비용 때문에 별도 사이클로 분리. follow-up 이슈 발행 예정. + - release 빌드에서 stack 숨김 (사용자 친화 메시지로 좁히기) — #342 종료 후 follow-up. + - 8 화면 UX 전체 — 남은 P1/P2 (chat 빈 상태, check_in 날짜 포맷, 프레임 레벨 라벨 명확화) 는 v0.4.3 또는 후속 이슈로. + +## 3. 인수조건 (Acceptance Criteria) +- [x] **AC-A1** Pixel/Galaxy gesture nav 단말에서 입력창 + send 버튼이 nav bar 와 겹치지 않음. +- [x] **AC-B1** send 실패 시 빨간 배너에 `LLM 응답 실패: \n\n--- STACK ---\n` 표시. SelectableText 라 복사 가능. +- [x] **AC-B2** 빨간 배너 높이는 화면의 1/3 을 넘지 않고, 내부 스크롤로 전체 노출. +- [x] **AC-C1** 습관 카드 부제가 `build · L3 · …` → `만들기 · …` (frameLevel 식별자 제거). +- [x] **AC-C2** 스트릭 화면 현재 티어가 `T0` / `T1` (raw) → `🌱 새싹` / `🥉 3회 도전` …. +- [x] **AC-C3** 스트릭 화면 강등 경고가 `Never miss twice 발동 — 티어 강등` → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.` (영문 잠언 제거). +- [x] **AC-C4** 습관 추가 드롭다운이 `만들기 (build)` → `만들기` (식별자 병기 제거). +- [x] **AC-C5** 스트릭 화면의 현재 스트릭이 `displayLarge` hero + 티어 라벨로 시각 위계 강조. +- [x] **AC-D** 167 기존 테스트 회귀 없음, `flutter analyze` clean. + +## 4. 컨텍스트 & 제약 +- **의존성**: flutter_gemma 0.16.5 (B 변경 안 함), Riverpod 2.x, Drift row 의 raw String enum. +- **제약**: + - dev 단계 hotfix — release 노출 가능한 stack 도 허용 (사용자 본인 단말 진단 우선). + - C 의 라벨 매핑은 UI 레이어 단일 지점 (`ui/labels.dart`) — domain enum 에 `koreanLabel` 두지 않음 (관심사 분리). +- **가정**: + - `h.type` 은 Drift row 의 String — `HabitTypeX.dbValue` 와 동일한 wire 값 (`'build'` / `'break'`). + - `RewardTier` 의 사용자 명칭은 메모리상 5-Tier 정의 — 🌱 새싹 / 🥉 3회 / 🥈 7일 / 🥇 30일 / 🏆 6주 완주. + +## 5. 아키텍처 개요 +순수 string 매핑 + Widget tree 재구성. 신규 모듈 없음. + +``` +ChatScreen +├─ Scaffold.body — SafeArea(top: false) ← AC-A1 +│ └─ Column +│ ├─ _WarmupErrorBanner (변경 없음) +│ ├─ Container(error) ← AC-B1/B2 +│ │ constraints: maxHeight: screen/3 +│ │ child: SingleChildScrollView(SelectableText, monospace 12pt) +│ └─ ListView (변경 없음) + +ChatSessionController.userTurn ← AC-B1 +└─ catch (e, st) → state.error = "LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st" + +ui/labels.dart ← AC-C1~C4 +├─ habitTypeLabel(HabitType) → '만들기' / '없애기' +├─ habitTypeLabelFromDb(String) → ↑ (Drift raw 분기, 기본 fallback = dbValue) +└─ rewardTierLabel(RewardTier) → '🌱 새싹' / '🥉 3회 도전' / … / '🏆 6주 완주' + +habit_list_screen / streak_screen / habit_create_screen +└─ raw enum 노출 지점 모두 labels.dart 의 함수로 교체 +``` + +## 6. 데이터 모델 +신규 모델 없음. 매핑 도메인은 기존 enum (`HabitType`, `FrameLevel`, `RewardTier`) 의 표현 레이어만 분리. + +| Enum | Raw (DB/wire) | UI 라벨 | +|---|---|---| +| `HabitType.build` | `'build'` | `만들기` | +| `HabitType.breakHabit` | `'break'` | `없애기` | +| `RewardTier.t0` | `'T0'` | `🌱 새싹` | +| `RewardTier.t1` | `'T1'` | `🥉 3회 도전` | +| `RewardTier.t2` | `'T2'` | `🥈 7일 형성` | +| `RewardTier.t3` | `'T3'` | `🥇 30일 정착` | +| `RewardTier.t4` | `'T4'` | `🏆 6주 완주` | + +`FrameLevel` 은 본 hotfix 에서 UI 노출을 **제거** — 사용자에 의미 모호 (L2/L3 차이가 즉시 보이지 않음). 라벨 매핑 미작성. + +## 7. 함수 명세 + +| 함수 | 책임 | 시그니처 | 복잡? | +|------|------|----------|-------| +| `habitTypeLabel` | enum → 한국어 라벨 | `String habitTypeLabel(HabitType)` | 단순 (switch) | +| `habitTypeLabelFromDb` | Drift raw String → 한국어 (fallback = raw) | `String habitTypeLabelFromDb(String)` | 단순 (switch + default) | +| `rewardTierLabel` | enum → 이모지+한국어 | `String rewardTierLabel(RewardTier)` | 단순 (switch) | + +모두 단순 string switch 라 `fn-*.md` 분리 불필요. + +## 8. 흐름 / 알고리즘 +- A: `Scaffold.body` 가 `SafeArea` 로 감싸지면서 system bottom inset 만큼 padding 자동 적용. `top: false` 인 이유는 AppBar 가 이미 top inset 처리 (이중 padding 방지). +- B: `Future.try-catch (e, st)` 에서 stack trace 까지 함께 string concat → state → 빨간 컨테이너의 `SelectableText` 로 노출. 사용자가 텍스트 선택 → 복사해 외부에 공유 가능. +- C: 라벨 매핑은 분기/상태/I/O 없음. switch one-liner. + +## 9. 테스트 전략 +- 신규 unit 추가 없음 — 라벨 매핑은 상수 매핑이라 unit 가치 낮음. +- SafeArea + 빨간 배너는 widget 레이어 변경이지만 LLM 단말 시도 자체가 차단 상태 (#312 corpus collection blocker) — manual 검증으로 대체. +- 167 기존 테스트 회귀 없음으로 단위/통합/도메인 보호. + +## 10. 후속 (v0.4.3 또는 별개 이슈) +- `BackendInitException: model may be invalid` 진단/수정 — `maxTokens=2048` 의 GPU buffer 후보. 단말 빌드 비용 때문에 분리. +- 남은 UX P1/P2 — chat 빈 상태 안내, check_in 한국식 날짜, 프레임 레벨 라벨 명확화. +- release 빌드에서 stack 숨김 (사용자 친화 메시지로). + +## 11. 추적성 +- **Redmine**: #342 (07-Release, dev hotfix bundle). +- **선행**: #311 (v0.4.1 warm-up — 빨간 배너 자체는 v0.4.1 에서 도입, 본 hotfix 가 진단성 강화). +- **관련**: #312 (corpus collection — LLM 동작 의존, B 진단 완료까지 블로커).