diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4831107..0854f47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: flutter-version: "3.44.0" channel: stable diff --git a/.github/workflows/deploy-ios.yml b/.github/workflows/deploy-ios.yml index 71d2f71..69899ce 100644 --- a/.github/workflows/deploy-ios.yml +++ b/.github/workflows/deploy-ios.yml @@ -23,11 +23,10 @@ jobs: # Apple 은 iOS 26 SDK(Xcode 26+)로 빌드한 앱만 업로드 허용(2026-06-15~). macos-latest # 가 아직 Xcode 16(iOS 18 SDK) 이미지라 409(SDK version) 가 나므로 macos-26 으로 고정. runs-on: macos-26 - env: - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + # 최소권한: ASC_KEY_ID/ASC_ISSUER_ID 는 job env 로 전 스텝(서드파티 액션 포함)에 흘리지 않고 + # 실제 쓰는 스텝(검증·키배치·빌드)의 step env 로만 좁힌다. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 # git rev-list 로 빌드번호(커밋 수)를 뽑으려면 전체 이력 필요 @@ -45,6 +44,8 @@ jobs: APPLE_DIST_CERT_P12: ${{ secrets.APPLE_DIST_CERT_P12 }} APPLE_DIST_CERT_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }} ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} run: | missing="" for v in SUPABASE_URL SUPABASE_ANON_KEY API_BASE_URL \ @@ -58,7 +59,7 @@ jobs: exit 1 fi - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: flutter-version: "3.44.0" channel: stable @@ -69,7 +70,7 @@ jobs: # 배포 인증서(.p12)를 임시 keychain 에 import — 자동서명이 새 인증서를 만들지 않고 # 기존 것을 재사용하게 해(인증서 한도 소모 방지) CI 에서도 결정적으로 서명한다. - name: Import Apple distribution certificate - uses: apple-actions/import-codesign-certs@v3 + uses: apple-actions/import-codesign-certs@63fff01cd422d4b7b855d40ca1e9d34d2de9427d # v3 with: p12-file-base64: ${{ secrets.APPLE_DIST_CERT_P12 }} p12-password: ${{ secrets.APPLE_DIST_CERT_PASSWORD }} @@ -78,6 +79,7 @@ jobs: - name: Place App Store Connect API key env: ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} run: | mkdir -p "$HOME/.appstoreconnect/private_keys" KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" diff --git a/.github/workflows/release-client.yml b/.github/workflows/release-client.yml index 7db704b..af0b7e1 100644 --- a/.github/workflows/release-client.yml +++ b/.github/workflows/release-client.yml @@ -11,8 +11,9 @@ # 필요한 repo secrets(Settings → Secrets and variables → Actions): # ANDROID_KEYSTORE_BASE64 = base64 < ieoseo-upload.jks # ANDROID_KEYSTORE_PASSWORD = 키스토어 비밀번호(=키 비밀번호) -# ANDROID_KEY_ALIAS = upload # PLAY_SERVICE_ACCOUNT_JSON = Google Play Console 서비스 계정 JSON(전체 내용) +# 참고: 키 alias 는 비밀이 아니라 고정값 `upload` 라 secret 이 아니라 아래 env 리터럴로 둔다 +# (secret 으로 두면 흔한 단어 "upload" 가 로그 곳곳에서 과도 마스킹된다). # SUPABASE_URL / SUPABASE_ANON_KEY / API_BASE_URL = 운영 dart-define 주입값(필수) name: release-client @@ -27,7 +28,7 @@ jobs: HAS_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} HAS_PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # 운영 dart-define 값이 비어 있으면 잘못된 서버를 가리키는 AAB 가 만들어져 # Play 에 그대로 올라갈 수 있다. 빌드 전에 빠르게 실패시켜 오배포를 막는다. @@ -46,7 +47,7 @@ jobs: exit 1 fi - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: flutter-version: "3.44.0" channel: stable @@ -60,7 +61,7 @@ jobs: env: KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_ALIAS: upload # 비밀 아님(고정 alias) — secret 과도 마스킹 회피 run: | echo "$KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/ieoseo-upload.jks" cat > android/key.properties </cmdline 에도 노출된다. + # 임시 JSON 파일 + --dart-define-from-file 로 빼서 시크릿이 명령줄에 안 실리게 한다(빌드 후 삭제). + define_file = "#{WORKSPACE}/ios/dart-define.prod.json" + File.write(define_file, JSON.generate( + "SUPABASE_URL" => ENV.fetch("SUPABASE_URL"), + "SUPABASE_ANON_KEY" => ENV.fetch("SUPABASE_ANON_KEY"), + "API_BASE_URL" => ENV.fetch("API_BASE_URL"), + )) + begin + sh( + "cd #{WORKSPACE} && flutter build ipa --release " \ + "--build-number=#{build_number} " \ + "--export-options-plist=ios/ExportOptions.plist " \ + "--dart-define-from-file=ios/dart-define.prod.json" + ) + ensure + File.delete(define_file) if File.exist?(define_file) + end ipa = Dir.glob("#{WORKSPACE}/build/ios/ipa/*.ipa").first UI.user_error!("빌드된 .ipa 를 찾지 못했어요") if ipa.nil? diff --git a/lib/data/api/api_repository.dart b/lib/data/api/api_repository.dart index 5ed6c28..8dade9d 100644 --- a/lib/data/api/api_repository.dart +++ b/lib/data/api/api_repository.dart @@ -58,8 +58,16 @@ class ApiRepository implements IeoseoRepository { Future deleteEvent(String id) => _client.delete('/events/$id'); @override - Future pinEvent(DkEvent event, {required bool pinned}) => - updateEvent(event.copyWith(pinned: pinned)); + Future completeEvent(String id) async { + final dynamic data = await _client.post('/events/$id/complete'); + return DkEventDto.fromJson(_asMap(data)); + } + + @override + Future reopenEvent(String id) async { + final dynamic data = await _client.post('/events/$id/reopen'); + return DkEventDto.fromJson(_asMap(data)); + } // ── Tasks ─────────────────────────────────────────────── @override diff --git a/lib/data/api/dtos.dart b/lib/data/api/dtos.dart index ab3e6d1..b482d16 100644 --- a/lib/data/api/dtos.dart +++ b/lib/data/api/dtos.dart @@ -68,6 +68,7 @@ abstract final class DkEventDto { pinned: json['pinned'] == true, memo: _str(json['memo']) ?? '', color: _str(json['color']) ?? 'cool', + completed: json['completed'] == true, ); /// [DkEvent] → 생성 요청 본문(EventCreateRequest). id/파생계산은 보내지 않는다. diff --git a/lib/data/data_controller.dart b/lib/data/data_controller.dart index df3d9fb..e77b7a8 100644 --- a/lib/data/data_controller.dart +++ b/lib/data/data_controller.dart @@ -147,6 +147,35 @@ class DataController extends ChangeNotifier { } } + /// 이벤트 종료(완료) 처리. 자동 삭제 대신 유저 명시 액션(FRD 5.1). + Future completeEvent(String id) => _setEventCompleted(id, true); + + /// 종료 취소(재개). + Future reopenEvent(String id) => _setEventCompleted(id, false); + + Future _setEventCompleted(String id, bool completed) async { + final List snapshot = _events; + // 낙관적 업데이트 후 server 응답으로 교체, 실패 시 롤백. + _events = _events + .map((DkEvent e) => e.id == id ? e.copyWith(completed: completed) : e) + .toList(); + notifyListeners(); + try { + final DkEvent updated = completed + ? await _repo.completeEvent(id) + : await _repo.reopenEvent(id); + _events = _events + .map((DkEvent e) => e.id == updated.id ? updated : e) + .toList(); + notifyListeners(); + return updated; + } on Exception { + _events = snapshot; + notifyListeners(); + rethrow; + } + } + // ── Debts ─────────────────────────────────────────────── Future carryDebt(String id, {required String toDate}) async { diff --git a/lib/data/dday.dart b/lib/data/dday.dart index 37a023e..6544e9a 100644 --- a/lib/data/dday.dart +++ b/lib/data/dday.dart @@ -90,17 +90,35 @@ DkDdayInfo ddayInfo(DkEvent ev, [DateTime? today]) { case DkEventType.period: final int toStart = daysBetween(t, ev.start!); final int toEnd = daysBetween(t, ev.end!); - final String status = toStart > 0 ? '예정' : (toEnd >= 0 ? '진행중' : '종료'); - final String label = toStart > 0 - ? '시작 D-$toStart' - : (toEnd > 0 ? '마감 D-$toEnd' : (toEnd == 0 ? '마감 D-DAY' : '종료')); + // 단계 전이: 시작 전 → 시작당일 → 진행중 → 마감당일 → 종료. + // 임박도(urgency)는 "지금 카운트다운 중인 경계" 기준으로 맞춘다 — 시작 전엔 + // 시작까지, 시작 후엔 마감까지. 종료(D+)는 past 로 강조를 해제한다. + final String status; + final String label; + final DkUrgency urgency; + if (toStart >= 0) { + // 시작 전(toStart>0) 또는 시작 당일(toStart==0). 시작일까지 카운트다운. + status = toStart == 0 ? '진행중' : '예정'; + label = toStart == 0 ? '시작 D-DAY' : '시작 D-$toStart'; + urgency = urgencyOf(toStart); + } else if (toEnd >= 0) { + // 진행 중. 마감일까지 카운트다운(마감 당일 = "마감 D-DAY"). + status = '진행중'; + label = toEnd == 0 ? '마감 D-DAY' : '마감 D-$toEnd'; + urgency = urgencyOf(toEnd); + } else { + // 종료: 마감일부터 경과(D+). 자동 삭제하지 않고 계속 노출(유저가 종료 처리). + status = '종료'; + label = '마감 D+${-toEnd}'; + urgency = DkUrgency.past; + } return DkDdayInfo( type: DkEventType.period, toStart: toStart, toEnd: toEnd, status: status, label: label, - urgency: urgencyOf(toEnd), + urgency: urgency, ); } } diff --git a/lib/data/mock_data.dart b/lib/data/mock_data.dart index 7d073ef..ab0ba91 100644 --- a/lib/data/mock_data.dart +++ b/lib/data/mock_data.dart @@ -16,7 +16,6 @@ List get kEvents => [ title: '정보처리기사 실기', category: '자격증', date: _d(28), - pinned: true, memo: '필답형 + 실무. 기출 5개년 정리하기.', color: 'blue', remindDays: const [7, 3, 1], diff --git a/lib/data/models.dart b/lib/data/models.dart index 5e395fa..9303eb6 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -186,6 +186,7 @@ class DkEvent { this.memo = '', this.color = 'cool', this.remindDays = const [], + this.completed = false, }); final String id; @@ -209,6 +210,10 @@ class DkEvent { final String color; final List remindDays; + /// 종료(완료) 처리 여부. true 면 홈 "다가오는 일정"에서 숨긴다(server `completed`). + /// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때만 true. + final bool completed; + /// nullable 날짜 필드(date/start/end)는 센티넬([_unset])로 받아 **null 로 비우기**도 /// 가능하게 한다(`field ?? this.field` 패턴은 null 설정을 못 함). 예: T1↔T2 전환 시 date 비우기. DkEvent copyWith({ @@ -223,6 +228,7 @@ class DkEvent { String? memo, String? color, List? remindDays, + bool? completed, }) { return DkEvent( id: id ?? this.id, @@ -236,6 +242,7 @@ class DkEvent { memo: memo ?? this.memo, color: color ?? this.color, remindDays: remindDays ?? this.remindDays, + completed: completed ?? this.completed, ); } } diff --git a/lib/data/repository.dart b/lib/data/repository.dart index f5e3da6..cbcc0c4 100644 --- a/lib/data/repository.dart +++ b/lib/data/repository.dart @@ -23,8 +23,12 @@ abstract class IeoseoRepository { /// 이벤트 삭제(server: DELETE /events/{id} → 204). Future deleteEvent(String id); - /// 홈 고정 토글. server 전용 엔드포인트가 없어 PUT 으로 `pinned` 만 갱신한다. - Future pinEvent(DkEvent event, {required bool pinned}); + /// 종료(완료) 처리(server: POST /events/{id}/complete). 갱신된 이벤트 반환. + /// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때 호출. + Future completeEvent(String id); + + /// 종료 취소(재개, server: POST /events/{id}/reopen). + Future reopenEvent(String id); // ── Tasks ─────────────────────────────────────────────── /// 태스크 목록(server: GET /tasks). [date] 지정 시 해당 일자만. @@ -145,8 +149,18 @@ class MockRepository implements IeoseoRepository { } @override - Future pinEvent(DkEvent event, {required bool pinned}) => - updateEvent(event.copyWith(pinned: pinned)); + Future completeEvent(String id) async => _setCompleted(id, true); + + @override + Future reopenEvent(String id) async => _setCompleted(id, false); + + DkEvent _setCompleted(String id, bool completed) { + final DkEvent updated = _events + .firstWhere((DkEvent e) => e.id == id) + .copyWith(completed: completed); + _events = _events.map((DkEvent e) => e.id == id ? updated : e).toList(); + return updated; + } @override Future> tasks({String? date}) async { diff --git a/lib/parts/app_header.dart b/lib/parts/app_header.dart index 3c27ecc..6ee3536 100644 --- a/lib/parts/app_header.dart +++ b/lib/parts/app_header.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../theme/tokens.dart'; +import '../widgets/dk_bell_button.dart'; import '../widgets/dk_icon.dart'; /// 화면 상단 헤더(인사 + 알림 벨). 프로토타입 `AppHeader`. @@ -89,38 +90,7 @@ class AppHeader extends StatelessWidget { right!, const SizedBox(width: 8), ], - GestureDetector( - onTap: onBell, - child: Container( - width: 44, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: t.bgPress, - borderRadius: BorderRadius.circular(14), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - DkIcon('bell', size: 21, color: t.fgMuted), - if (unread > 0) - Positioned( - top: -2, - right: -1, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: t.danger, - shape: BoxShape.circle, - border: Border.all(color: t.bgPress, width: 2), - ), - ), - ), - ], - ), - ), - ), + DkBellButton(unread: unread, onTap: onBell), ], ), ), diff --git a/lib/parts/dday_hero.dart b/lib/parts/dday_hero.dart index 5474c4b..0031f57 100644 --- a/lib/parts/dday_hero.dart +++ b/lib/parts/dday_hero.dart @@ -12,7 +12,7 @@ import '../widgets/dk_icon.dart'; /// D-Day 히어로 카드. 프로토타입 `DdayHero`. /// /// fg-strong 배경/흰 글자, radius-lg, shadow-2. 우상단 hue 글로우 원(blur). -/// 상단: 고정 칩 + 카테고리 + (urgency high면) "마감 임박" 솔리드 뱃지. +/// 상단: 카테고리 + (urgency high면) "마감 임박" 솔리드 뱃지. /// 하단: 제목·날짜 + 큰 D-라벨(brand 52/800). class DdayHero extends StatelessWidget { const DdayHero({super.key, required this.event, this.onOpen}); @@ -62,10 +62,6 @@ class DdayHero extends StatelessWidget { children: [ Row( children: [ - if (event.pinned) ...[ - _pill(white), - const SizedBox(width: 8), - ], Text( event.category, style: const TextStyle( @@ -148,30 +144,4 @@ class DdayHero extends StatelessWidget { ), ); } - - Widget _pill(Color white) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: const Color(0x24FFFFFF), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - DkIcon('pin', size: 13, color: white, strokeWidth: 2), - const SizedBox(width: 4), - const Text( - '고정됨', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xD1FFFFFF), - ), - ), - ], - ), - ); - } } diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 939d3de..e328583 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -28,6 +28,7 @@ import 'loading_skeleton.dart'; import 'plan/plan_summary.dart'; import 'review/review_screen.dart'; import 'review/week_review_builder.dart'; +import 'sheets/add_sheet.dart'; import 'sheets/event_sheet.dart'; import 'sheets/notif_sheet.dart'; import 'sheets/task_sheet.dart'; @@ -261,6 +262,27 @@ class _MainScaffoldState extends State ); } + /// 하단 + (FAB): 통합 추가 시트(상단 [할 일 | D-Day 일정] 세그먼트)로 한 시트에서 분기한다. + /// 태스크와 D-Day 는 별개 도메인이라(홈 "다가오는 일정"=D-Day 만) 폼은 탭으로 전환한다. + void _openAddSheet() { + showAddSheet( + context, + onToast: _toastNamed, + onAddTask: (DkTask draft) => _run( + () => _c.createTask(draft), + success: '태스크를 추가했어요', + successIcon: 'plus', + successTone: DkTone.primary, + ), + onAddEvent: (DkEvent draft) => _run( + () => _c.createEvent(draft), + success: '이벤트를 추가했어요', + successIcon: 'plus', + successTone: DkTone.primary, + ), + ); + } + void _addTask() { showTaskSheet( context, @@ -288,6 +310,19 @@ class _MainScaffoldState extends State ), onSubmit: (DkEvent draft) => _run(() => _c.updateEvent(draft), success: '이벤트를 저장했어요'), + onComplete: (DkEvent e) => e.completed + ? _run( + () => _c.reopenEvent(e.id), + success: '종료를 취소했어요', + successIcon: 'repeat', + successTone: DkTone.info, + ) + : _run( + () => _c.completeEvent(e.id), + success: '일정을 종료했어요', + successIcon: 'check', + successTone: DkTone.success, + ), ); } @@ -454,7 +489,7 @@ class _MainScaffoldState extends State DkTabBar( active: _tab, onChanged: (DkTab tb) => setState(() => _tab = tb), - onAdd: _addTask, + onAdd: _openAddSheet, ), ], ), @@ -542,13 +577,11 @@ class _MainScaffoldState extends State switch (_tab) { case DkTab.today: return TodayScreen( - userName: widget.user.nickname, events: _c.events, debts: _c.debts, unread: unread, onOpenEvent: _openEventSheet, onBell: _openNotif, - onOpenCalc: _openCalc, onOpenDebt: () => setState(() => _sub = _Sub.debt), ); case DkTab.plan: @@ -571,7 +604,12 @@ class _MainScaffoldState extends State onBell: _openNotif, ); case DkTab.stats: - return ReviewScreen(review: _weekReview(), streak: _streakDays()); + return ReviewScreen( + review: _weekReview(), + streak: _streakDays(), + unread: unread, + onBell: _openNotif, + ); case DkTab.me: return MeScreen( user: widget.user, diff --git a/lib/screens/me/me_screen.dart b/lib/screens/me/me_screen.dart index 1e0fd42..b4519b7 100644 --- a/lib/screens/me/me_screen.dart +++ b/lib/screens/me/me_screen.dart @@ -113,7 +113,6 @@ class MeScreen extends StatelessWidget { _statTile( t, icon: 'flame', - iconBg: t.warningSubtle, iconColor: t.warningFg, fill: true, value: streak.toDouble(), @@ -124,7 +123,6 @@ class MeScreen extends StatelessWidget { _statTile( t, icon: 'chart', - iconBg: t.successSubtle, iconColor: t.successFg, value: pct.toDouble(), suffix: '%', @@ -134,7 +132,6 @@ class MeScreen extends StatelessWidget { _statTile( t, icon: 'focus', - iconBg: t.primarySubtle, iconColor: t.primary, value: focusStats.todaySessions.toDouble(), suffix: '회', @@ -270,7 +267,6 @@ class MeScreen extends StatelessWidget { Widget _statTile( DkTokens t, { required String icon, - required Color iconBg, required Color iconColor, required double value, required String suffix, @@ -289,23 +285,15 @@ class MeScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 34, - height: 34, - alignment: Alignment.center, - decoration: BoxDecoration( - color: iconBg, - borderRadius: BorderRadius.circular(11), - ), - child: DkIcon( - icon, - size: 19, - color: iconColor, - strokeWidth: 1.95, - fill: fill ? iconColor : null, - ), + // 아이콘 뒤 컬러 타일 제거 — 아이콘만 색으로 노출(템플릿 느낌 완화). + DkIcon( + icon, + size: 24, + color: iconColor, + strokeWidth: 1.95, + fill: fill ? iconColor : null, ), - const SizedBox(height: 8), + const SizedBox(height: 10), DkCountUp( value: value, suffix: suffix, diff --git a/lib/screens/plan/calendar_screen.dart b/lib/screens/plan/calendar_screen.dart index 0f07f77..7b239c8 100644 --- a/lib/screens/plan/calendar_screen.dart +++ b/lib/screens/plan/calendar_screen.dart @@ -38,7 +38,7 @@ class CalendarScreen extends StatefulWidget { State createState() => _CalendarScreenState(); } -enum _CalView { month, week, day } +enum _CalView { month, week } class _CalendarScreenState extends State { _CalView _view = _CalView.month; @@ -47,30 +47,71 @@ class _CalendarScreenState extends State { /// 표시 중인 월(선택과 독립). 이전/다음 달로 이동할 수 있게 별도 상태로 둔다. DateTime _month = DateTime(kToday.year, kToday.month); + // 주간 PageView(주 단위 캐러셀 슬라이드 — 끌면 옆 주가 따라오고 ‹›도 슬라이드). + // 페이지 = anchor 주로부터의 주 수. anchor 는 주간 진입 시점 선택일의 월요일. + PageController? _weekCtrl; + DateTime _weekAnchorMonday = addDays(kToday, 1 - kToday.weekday); + static const int _weekAnchorPage = 100000; + static const Duration _kWeekAnim = Duration(milliseconds: 300); + + DateTime _mondayOf(DateTime d) => addDays(d, 1 - d.weekday); + DateTime _mondayForPage(int page) => + addDays(_weekAnchorMonday, (page - _weekAnchorPage) * 7); + + /// UTC 자정 기준 일수(주 차이 계산용 — DST 영향 없음). + int _epochDay(DateTime d) => + DateTime.utc(d.year, d.month, d.day).millisecondsSinceEpoch ~/ + Duration.millisecondsPerDay; + int _pageForMonday(DateTime mon) => + _weekAnchorPage + (_epochDay(mon) - _epochDay(_weekAnchorMonday)) ~/ 7; + + @override + void dispose() { + _weekCtrl?.dispose(); + super.dispose(); + } + void _shiftMonth(int delta) => setState(() => _month = DateTime(_month.year, _month.month + delta)); - /// 오늘로 복귀: 표시 월·선택을 오늘로 맞춘다. + /// 뷰 전환. 주간 진입 시 anchor=현재 선택주로 PageController 생성, 이탈 시 해제. + void _setView(_CalView v) { + setState(() { + if (v == _CalView.week) { + _weekAnchorMonday = _mondayOf(parseYmd(_sel)); + _weekCtrl?.dispose(); + _weekCtrl = PageController(initialPage: _weekAnchorPage); + } else { + _weekCtrl?.dispose(); + _weekCtrl = null; + } + _view = v; + }); + } + + /// 오늘로 복귀: 표시 월·선택을 오늘로 맞춘다(주간이면 해당 주로 슬라이드). void _goToday() { setState(() { _month = DateTime(kToday.year, kToday.month); _sel = ymd(kToday); }); + final PageController? c = _weekCtrl; + if (_view == _CalView.week && c != null && c.hasClients) { + c.animateToPage( + _pageForMonday(_mondayOf(kToday)), + duration: _kWeekAnim, + curve: Curves.easeOutCubic, + ); + } } - /// 선택일을 [delta]일만큼 이동(주간=±7, 일간=±1). - void _shiftDays(int delta) { - setState(() => _sel = ymd(addDays(parseYmd(_sel), delta))); - } - - /// 좌우 스와이프 → [unit]일 단위 이동(왼쪽=다음, 오른쪽=이전). - void _onSwipe(DragEndDetails d, int unit) { - final double v = d.primaryVelocity ?? 0; - if (v < -200) { - _shiftDays(unit); - } else if (v > 200) { - _shiftDays(-unit); - } + /// PageView 페이지 변경 → 선택 요일을 유지하며 해당 주로 이동. + void _onWeekPage(int page) { + setState(() { + final DateTime monday = _mondayForPage(page); + final int weekdayOffset = parseYmd(_sel).weekday - 1; // 0=월..6=일 + _sel = ymd(addDays(monday, weekdayOffset)); + }); } void _openItem(DayItem it) { @@ -93,11 +134,10 @@ class _CalendarScreenState extends State { child: DkSegmented<_CalView>( full: true, value: _view, - onChanged: (_CalView v) => setState(() => _view = v), + onChanged: _setView, options: const >[ DkSegment<_CalView>(_CalView.month, '월간'), DkSegment<_CalView>(_CalView.week, '주간'), - DkSegment<_CalView>(_CalView.day, '일간'), ], ), ), @@ -120,30 +160,34 @@ class _CalendarScreenState extends State { ), ), ), - // 주간: 주 단위 네비(±7) + 오늘, 좌우 스와이프로 주 이동. + // 주간: 주 단위 네비(‹›) + 오늘. PageView 로 주를 페이징해 손가락으로 끌면 옆 주가 + // 따라오고(이전·다음 주가 겹쳐 보이며 슬라이드), ‹› 도 같은 슬라이드로 넘어간다. if (_view == _CalView.week) ...[ _CalNav( - onPrev: () => _shiftDays(-7), - onNext: () => _shiftDays(7), + onPrev: () => _weekCtrl?.previousPage( + duration: _kWeekAnim, + curve: Curves.easeOutCubic, + ), + onNext: () => _weekCtrl?.nextPage( + duration: _kWeekAnim, + curve: Curves.easeOutCubic, + ), onToday: _goToday, ), const SizedBox(height: 6), - GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragEnd: (DragEndDetails e) => _onSwipe(e, 7), - child: WeekStrip( - selected: _sel, - onSelect: (String k) => setState(() => _sel = k), + SizedBox( + height: 76, + child: PageView.builder( + controller: _weekCtrl, + onPageChanged: _onWeekPage, + itemBuilder: (BuildContext _, int page) => WeekStrip( + weekStart: _mondayForPage(page), + selected: _sel, + onSelect: (String k) => setState(() => _sel = k), + ), ), ), ], - // 일간: 하루 단위 네비(±1) + 오늘(스와이프는 아래 일정 영역에서 ±1). - if (_view == _CalView.day) - _CalNav( - onPrev: () => _shiftDays(-1), - onNext: () => _shiftDays(1), - onToday: _goToday, - ), const SizedBox(height: 18), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), @@ -152,20 +196,14 @@ class _CalendarScreenState extends State { title: '${d.month}월 ${d.day}일 (${kWeekdaysKo[d.weekday % 7]})', ), ), - GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragEnd: _view == _CalView.day - ? (DragEndDetails e) => _onSwipe(e, 1) - : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: _DayList( - dateStr: _sel, - tasks: widget.tasks, - events: widget.events, - externals: widget.externals, - onOpen: _openItem, - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _DayList( + dateStr: _sel, + tasks: widget.tasks, + events: widget.events, + externals: widget.externals, + onOpen: _openItem, ), ), // FAB 가림 방지용 하단 여백(하단 출처 로고 범례는 제거 — 출처 구분은 @@ -208,7 +246,7 @@ Widget _todayChip(DkTokens t, VoidCallback onTap) { ); } -/// 주간/일간 뷰 네비: 이전 ‹ · 오늘 · 다음 ›. +/// 주간 뷰 네비: 이전 ‹ · 오늘 · 다음 ›. class _CalNav extends StatelessWidget { const _CalNav({ required this.onPrev, diff --git a/lib/screens/plan/task_screen.dart b/lib/screens/plan/task_screen.dart index ccd4255..7f45ba6 100644 --- a/lib/screens/plan/task_screen.dart +++ b/lib/screens/plan/task_screen.dart @@ -107,12 +107,11 @@ class _TaskScreenState extends State { ], Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + // 추가 진입점은 하단 FAB(+)·빈 상태 CTA 로 충분해, 헤더의 중복 '추가' 는 제거. child: DkSectionHead( title: isToday ? '오늘의 할 일' : '${d.day}일 (${kWeekdaysKo[d.weekday % 7]}) 할 일', - action: '추가', - onAction: widget.onAddTask, ), ), Padding( diff --git a/lib/screens/plan/week_strip.dart b/lib/screens/plan/week_strip.dart index f215ae3..f589de5 100644 --- a/lib/screens/plan/week_strip.dart +++ b/lib/screens/plan/week_strip.dart @@ -7,18 +7,27 @@ import '../../theme/tokens.dart'; /// /// 각 칸: 요일 라벨 + 날짜 36 radius 12. 오늘=테두리, 선택=primary 채움. class WeekStrip extends StatelessWidget { - const WeekStrip({super.key, required this.selected, required this.onSelect}); + const WeekStrip({ + super.key, + required this.selected, + required this.onSelect, + this.weekStart, + }); /// 선택된 날짜 `YYYY-MM-DD`. final String selected; final ValueChanged onSelect; + /// 표시할 주의 시작(월요일). 지정하면 그 주를 그린다(PageView 페이지별 주). null 이면 + /// 선택일이 속한 주를 파생한다(단독 사용 시). + final DateTime? weekStart; + @override Widget build(BuildContext context) { final DkTokens t = DkTheme.of(context); - // 주 시작은 선택일이 속한 주의 월요일에서 파생(주 이동 시 해당 주를 보여준다). + // 주 시작: 명시되면 그 주, 아니면 선택일이 속한 주의 월요일. final DateTime selDate = parseYmd(selected); - final DateTime start = addDays(selDate, 1 - selDate.weekday); + final DateTime start = weekStart ?? addDays(selDate, 1 - selDate.weekday); final String todayKey = ymd(kToday); return Padding( diff --git a/lib/screens/review/review_screen.dart b/lib/screens/review/review_screen.dart index eda6c3f..c970da0 100644 --- a/lib/screens/review/review_screen.dart +++ b/lib/screens/review/review_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import '../../data/format.dart'; import '../../data/meta.dart'; +import '../../parts/app_header.dart'; import '../../theme/tokens.dart'; import '../../widgets/dk_card.dart'; import '../../widgets/dk_feedback.dart'; @@ -18,12 +19,20 @@ class ReviewScreen extends StatelessWidget { super.key, required this.review, required this.streak, + this.unread = 0, + this.onBell, this.onBack, }); final DkWeekReview review; final int streak; + /// 안 읽은 알림 수(헤더 벨 점 표시용, 이슈 #46). + final int unread; + + /// 우상단 알림 벨 탭. null 이면 벨 미표시(다른 탭들과 동일한 헤더 동작). + final VoidCallback? onBell; + /// 서브화면으로 열릴 때 뒤로가기. 통계 탭(최상위)으로 쓰일 땐 null → 뒤로가기 미표시. final VoidCallback? onBack; @@ -47,7 +56,13 @@ class ReviewScreen extends StatelessWidget { return ListView( padding: EdgeInsets.zero, children: [ - _header(t), + AppHeader( + title: '주간 리뷰', + subtitle: review.range, + unread: unread, + onBell: onBell, + onBack: onBack, + ), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 120), child: Column( @@ -71,56 +86,6 @@ class ReviewScreen extends StatelessWidget { ); } - Widget _header(DkTokens t) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 58, 16, 8), - child: Row( - children: [ - if (onBack != null) ...[ - GestureDetector( - onTap: onBack, - child: Container( - width: 44, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: t.bgPress, - borderRadius: BorderRadius.circular(12), - ), - child: DkIcon('chevL', size: 22, color: t.fg), - ), - ), - const SizedBox(width: 8), - ], - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '주간 리뷰', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.66, - color: t.fgStrong, - ), - ), - Text( - review.range, - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: t.fgSubtle, - ), - ), - ], - ), - ], - ), - ); - } - Widget _hero(DkTokens t, int pct) { const Color white = Color(0xFFFFFFFF); return ClipRRect( @@ -366,21 +331,13 @@ class ReviewScreen extends StatelessWidget { padding: 16, child: Row( children: [ - Container( - width: 46, - height: 46, - alignment: Alignment.center, - decoration: BoxDecoration( - color: t.warningSubtle, - borderRadius: BorderRadius.circular(14), - ), - child: DkIcon( - 'flame', - size: 24, - color: t.warningFg, - strokeWidth: 1.9, - fill: t.warningFg, - ), + // 아이콘 뒤 컬러 타일 제거 — 통계 타일과 동일하게 색 아이콘만 노출. + DkIcon( + 'flame', + size: 28, + color: t.warningFg, + strokeWidth: 1.9, + fill: t.warningFg, ), const SizedBox(width: 14), Expanded( diff --git a/lib/screens/sheets/add_sheet.dart b/lib/screens/sheets/add_sheet.dart new file mode 100644 index 0000000..aa3b44a --- /dev/null +++ b/lib/screens/sheets/add_sheet.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; + +import '../../data/models.dart'; +import '../../widgets/dk_segmented.dart'; +import '../../widgets/dk_sheet.dart'; +import 'event_sheet.dart'; +import 'task_sheet.dart'; + +/// 추가 시트의 상단 탭(무엇을 추가할지). +enum _AddTab { task, event } + +/// 통합 추가 시트. 하단 + 로 열며, **상단 세그먼트 [할 일 | D-Day 일정]** 로 한 시트 안에서 +/// 종류를 전환한다(별도 선택지 시트 없이). 태스크(할 일)와 이벤트(D-Day)는 별개 도메인이라 +/// 폼이 다르므로, 선택한 탭에 맞는 본문(`TaskSheetBody`/`EventSheetBody`)을 보여준다. +Future showAddSheet( + BuildContext context, { + required ValueChanged onAddTask, + required ValueChanged onAddEvent, + required void Function(String message, String icon, String tone) onToast, +}) { + return showDkSheet( + context, + title: '추가', + full: true, + child: Builder( + builder: (BuildContext sheetContext) => _AddSheetBody( + onClose: () => Navigator.of(sheetContext).maybePop(), + onAddTask: onAddTask, + onAddEvent: onAddEvent, + onToast: onToast, + ), + ), + ); +} + +class _AddSheetBody extends StatefulWidget { + const _AddSheetBody({ + required this.onClose, + required this.onAddTask, + required this.onAddEvent, + required this.onToast, + }); + + final VoidCallback onClose; + final ValueChanged onAddTask; + final ValueChanged onAddEvent; + final void Function(String message, String icon, String tone) onToast; + + @override + State<_AddSheetBody> createState() => _AddSheetBodyState(); +} + +class _AddSheetBodyState extends State<_AddSheetBody> { + _AddTab _tab = _AddTab.task; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DkSegmented<_AddTab>( + full: true, + value: _tab, + onChanged: (_AddTab v) => setState(() => _tab = v), + options: const >[ + DkSegment<_AddTab>(_AddTab.task, '할 일'), + DkSegment<_AddTab>(_AddTab.event, 'D-Day 일정'), + ], + ), + const SizedBox(height: 16), + // 탭별 폼. 신규 추가 전용이라 isNew=true(편집은 각 항목 탭 시 전용 시트로). + if (_tab == _AddTab.task) + TaskSheetBody( + isNew: true, + onClose: widget.onClose, + onSubmit: widget.onAddTask, + onToast: widget.onToast, + ) + else + EventSheetBody( + isNew: true, + onClose: widget.onClose, + onSubmit: widget.onAddEvent, + ), + ], + ); + } +} diff --git a/lib/screens/sheets/event_sheet.dart b/lib/screens/sheets/event_sheet.dart index 1e47637..21f3a97 100644 --- a/lib/screens/sheets/event_sheet.dart +++ b/lib/screens/sheets/event_sheet.dart @@ -24,6 +24,7 @@ class EventSheetBody extends StatefulWidget { required this.onClose, this.onDelete, this.onSubmit, + this.onComplete, }); final DkEvent? event; @@ -34,6 +35,9 @@ class EventSheetBody extends StatefulWidget { /// 추가/저장 제출. 폼 값으로 만든 초안(신규는 id 빈 문자열)을 전달한다. final ValueChanged? onSubmit; + /// 종료(완료) 토글. 현재 이벤트를 전달하면 호출부가 `completed` 에 따라 종료/재개한다. + final ValueChanged? onComplete; + @override State createState() => _EventSheetBodyState(); } @@ -41,7 +45,6 @@ class EventSheetBody extends StatefulWidget { class _EventSheetBodyState extends State { late DkEventType _type = widget.event?.type ?? DkEventType.single; late String _cat = widget.event?.category ?? '자격증'; - late bool _pinned = widget.event?.pinned ?? false; bool _remind = true; late final TextEditingController _title = TextEditingController( text: widget.event?.title ?? '', @@ -81,7 +84,8 @@ class _EventSheetBodyState extends State { date: single ? _date.text.trim() : null, start: single ? null : _start.text.trim(), end: single ? null : _end.text.trim(), - pinned: _pinned, + // 핀(홈 고정) 기능 제거 — 신규는 false, 편집은 기존 값 보존(서버 계약 필드는 유지). + pinned: widget.event?.pinned ?? false, memo: _memo.text.trim(), color: widget.event?.color ?? 'cool', ); @@ -236,10 +240,6 @@ class _EventSheetBodyState extends State { ), child: Column( children: [ - _toggleRow(t, 'pin', '홈 화면에 고정', _pinned, (bool v) { - setState(() => _pinned = v); - }), - Container(height: 1, color: t.borderSubtle), _toggleRow(t, 'bell', 'D-Day 알림 (7·3·1일 전)', _remind, (bool v) { setState(() => _remind = v); }), @@ -387,30 +387,66 @@ class _EventSheetBodyState extends State { } Widget _stickyActions(DkTokens t, DkEvent? ev) { - return Row( + final bool isCompleted = ev?.completed ?? false; + return Column( + mainAxisSize: MainAxisSize.min, children: [ - if (!widget.isNew && ev != null) ...[ - DkButton( - size: DkButtonSize.lg, - variant: DkButtonVariant.danger, - onPressed: () { - widget.onDelete?.call(ev); - widget.onClose(); - }, - leading: DkIcon('trash', size: 18, color: t.danger, strokeWidth: 2), + // 종료(완료) 토글 — 기존 이벤트만. 자동 삭제 대신 유저가 명시적으로 종료(FRD 5.1). + if (!widget.isNew && + ev != null && + widget.onComplete != null) ...[ + SizedBox( + width: double.infinity, + child: DkButton( + size: DkButtonSize.lg, + full: true, + variant: DkButtonVariant.outline, + onPressed: () { + widget.onComplete!.call(ev); + widget.onClose(); + }, + leading: DkIcon( + isCompleted ? 'repeat' : 'check', + size: 18, + color: t.fg, + strokeWidth: 2, + ), + child: Text(isCompleted ? '종료 취소' : '종료 처리'), + ), ), - const SizedBox(width: 10), + const SizedBox(height: 10), ], - Expanded( - child: DkButton( - size: DkButtonSize.lg, - full: true, - onPressed: () { - widget.onSubmit?.call(_draft()); - widget.onClose(); - }, - child: Text(widget.isNew ? '추가하기' : '저장'), - ), + Row( + children: [ + if (!widget.isNew && ev != null) ...[ + DkButton( + size: DkButtonSize.lg, + variant: DkButtonVariant.danger, + onPressed: () { + widget.onDelete?.call(ev); + widget.onClose(); + }, + leading: DkIcon( + 'trash', + size: 18, + color: t.danger, + strokeWidth: 2, + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: DkButton( + size: DkButtonSize.lg, + full: true, + onPressed: () { + widget.onSubmit?.call(_draft()); + widget.onClose(); + }, + child: Text(widget.isNew ? '추가하기' : '저장'), + ), + ), + ], ), ], ); @@ -424,6 +460,7 @@ Future showEventSheet( required bool isNew, ValueChanged? onDelete, ValueChanged? onSubmit, + ValueChanged? onComplete, }) { return showDkSheet( context, @@ -436,6 +473,7 @@ Future showEventSheet( onClose: () => Navigator.of(sheetContext).maybePop(), onDelete: onDelete, onSubmit: onSubmit, + onComplete: onComplete, ), ), ); diff --git a/lib/screens/sheets/notif_sheet.dart b/lib/screens/sheets/notif_sheet.dart index aea735e..175b311 100644 --- a/lib/screens/sheets/notif_sheet.dart +++ b/lib/screens/sheets/notif_sheet.dart @@ -19,17 +19,22 @@ import '../../widgets/dk_sheet.dart'; /// 알림 목록(server 실데이터, 이슈 #46). 프로토타입 `NotifSheet`. /// /// 안 읽은 항목은 점으로 강조하고, 항목 탭 시 [onTapItem] 으로 읽음 처리를 위임한다. +/// 안 읽은 항목이 하나라도 있으면 상단에 "모두 읽음"([onMarkAllRead]) 액션을 노출한다. /// 항목이 없으면 빈 상태를 보인다. 시각은 `createdAt`(ISO) 을 상대 표기로 환산한다. class NotifSheetBody extends StatelessWidget { const NotifSheetBody({ super.key, required this.items, required this.onTapItem, + this.onMarkAllRead, }); final List items; final ValueChanged onTapItem; + /// "모두 읽음" 탭. null 이면 액션을 숨긴다(읽음 처리 위임 없음). + final VoidCallback? onMarkAllRead; + @override Widget build(BuildContext context) { final DkTokens t = DkTheme.of(context); @@ -40,8 +45,42 @@ class NotifSheetBody extends StatelessWidget { body: '마감·미룬 시간·스트릭 소식이 생기면 여기에서 알려드릴게요.', ); } + final bool hasUnread = items.any((DkNotif n) => !n.read); return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (hasUnread && onMarkAllRead != null) + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onMarkAllRead, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 4, 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DkIcon( + 'check', + size: 16, + color: t.primary, + strokeWidth: 2.2, + ), + const SizedBox(width: 4), + Text( + '모두 읽음', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 13, + fontWeight: FontWeight.w600, + color: t.primary, + ), + ), + ], + ), + ), + ), + ), for (int i = 0; i < items.length; i++) GestureDetector( behavior: HitTestBehavior.opaque, @@ -149,6 +188,7 @@ Future showNotifSheet( builder: (BuildContext context, _) => NotifSheetBody( items: controller.items, onTapItem: onTapItem ?? (DkNotif n) => controller.markRead(n.id), + onMarkAllRead: controller.markAllRead, ), ), ); diff --git a/lib/screens/today/today_logic.dart b/lib/screens/today/today_logic.dart index bce83d1..cc831e9 100644 --- a/lib/screens/today/today_logic.dart +++ b/lib/screens/today/today_logic.dart @@ -54,15 +54,13 @@ int _ddayKey(DkEvent ev, DateTime today) { return (1 << 29) - marks.last; } -/// 이벤트를 D-Day 임박순으로 정렬한다(고정 pin 먼저, 그다음 임박한 순). -/// 원본은 변형하지 않고 새 리스트를 반환한다. +/// 이벤트를 D-Day 임박순으로 정렬한다(가장 임박한 순). 원본은 변형하지 않고 새 리스트 반환. List ddayOrdered(List events, {DateTime? today}) { final DateTime t = today ?? kToday; final List ordered = [...events]; - ordered.sort((DkEvent a, DkEvent b) { - if (a.pinned != b.pinned) return a.pinned ? -1 : 1; - return _ddayKey(a, t).compareTo(_ddayKey(b, t)); - }); + ordered.sort( + (DkEvent a, DkEvent b) => _ddayKey(a, t).compareTo(_ddayKey(b, t)), + ); return ordered; } diff --git a/lib/screens/today/today_screen.dart b/lib/screens/today/today_screen.dart index 101943c..889b363 100644 --- a/lib/screens/today/today_screen.dart +++ b/lib/screens/today/today_screen.dart @@ -1,10 +1,9 @@ -import 'dart:ui' as ui; - import 'package:flutter/widgets.dart'; import '../../data/dday.dart'; import '../../data/format.dart'; import '../../data/models.dart'; +import '../../parts/app_header.dart'; import '../../theme/tokens.dart'; import '../../widgets/dk_badge.dart'; import '../../widgets/dk_card.dart'; @@ -27,19 +26,14 @@ String _todayLabel() { class TodayScreen extends StatelessWidget { const TodayScreen({ super.key, - required this.userName, required this.events, required this.debts, required this.onOpenEvent, required this.onBell, - required this.onOpenCalc, required this.onOpenDebt, this.unread = 0, }); - /// 인사말에 쓸 사용자 닉네임(실제 로그인 사용자). - final String userName; - final List events; final List debts; @@ -47,12 +41,14 @@ class TodayScreen extends StatelessWidget { final int unread; final ValueChanged onOpenEvent; final VoidCallback onBell; - final VoidCallback onOpenCalc; final VoidCallback onOpenDebt; @override Widget build(BuildContext context) { - final List ordered = ddayOrdered(events); + // 종료(완료) 처리한 이벤트는 홈에서 숨긴다(미종료는 D+ 로 계속 노출). FRD 5.1. + final List ordered = ddayOrdered( + events.where((DkEvent e) => !e.completed).toList(growable: false), + ); final int debtTotal = debts.fold(0, (int s, DkDebt d) => s + d.mins); final int debtOverdue = debts .where((DkDebt d) => d.status == DkDebtStatus.overdue) @@ -61,17 +57,10 @@ class TodayScreen extends StatelessWidget { return ListView( padding: const EdgeInsets.only(bottom: 120), children: [ - const SizedBox(height: 54), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: _Header( - userName: userName, - unread: unread, - onBell: onBell, - onOpenCalc: onOpenCalc, - ), - ), - const SizedBox(height: 26), + // 다른 탭과 동일한 AppHeader — 좌측 상단에 오늘 날짜(제목), 우측 상단에 알림 벨. + // ink 인사 카드는 제거(요청)하고 헤더 위치/벨을 4개 탭에서 통일한다. + AppHeader(title: _todayLabel(), unread: unread, onBell: onBell), + const SizedBox(height: 8), const Padding( padding: EdgeInsets.fromLTRB(20, 0, 20, 12), child: DkSectionLabel('다가오는 일정'), @@ -109,133 +98,6 @@ class TodayScreen extends StatelessWidget { } } -/// ink 인사 헤더. 날짜 + 인사 + 계산기/알림 버튼. -class _Header extends StatelessWidget { - const _Header({ - required this.userName, - required this.unread, - required this.onBell, - required this.onOpenCalc, - }); - - final String userName; - final int unread; - final VoidCallback onBell; - final VoidCallback onOpenCalc; - - @override - Widget build(BuildContext context) { - final DkTokens t = DkTheme.of(context); - - return ClipRRect( - borderRadius: BorderRadius.circular(28), - child: Container( - decoration: BoxDecoration( - color: t.ink, - borderRadius: BorderRadius.circular(28), - boxShadow: t.shadows.s3, - ), - child: Stack( - children: [ - Positioned( - top: -90, - right: -60, - child: ImageFiltered( - imageFilter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - width: 240, - height: 240, - decoration: BoxDecoration( - color: t.primary.withValues(alpha: 0.28), - shape: BoxShape.circle, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(22, 20, 22, 22), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _todayLabel(), - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: t.onInkMuted, - ), - ), - const SizedBox(height: 2), - Text( - '안녕하세요, $userName님', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.66, - color: t.onInk, - ), - ), - ], - ), - ), - _inkBtn('calc', onOpenCalc, t), - const SizedBox(width: 8), - _inkBtn('bell', onBell, t, dot: unread > 0), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _inkBtn( - String icon, - VoidCallback onTap, - DkTokens t, { - bool dot = false, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x1FFFFFFF), - borderRadius: BorderRadius.circular(13), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - DkIcon(icon, size: 20, color: t.onInk), - if (dot) - Positioned( - top: -3, - right: -2, - child: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: t.danger, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ), - ); - } -} - /// 다가오는 일정 행(임박순 D-Day). 좌측 hue 아이콘 + 제목·카테고리, 우측 큰 D-값. class _DdayRow extends StatelessWidget { const _DdayRow({required this.event, required this.onOpen}); @@ -272,7 +134,13 @@ class _DdayRow extends StatelessWidget { color: h.subtle, borderRadius: BorderRadius.circular(12), ), - child: DkIcon('target', size: 19, color: h.color, strokeWidth: 2), + // 카테고리에 맞는 아이콘(자격증→graduationCap 등). 미매핑은 target 폴백. + child: DkIcon( + kCategoryIcon[event.category] ?? 'target', + size: 19, + color: h.color, + strokeWidth: 2, + ), ), const SizedBox(width: 13), Expanded( @@ -295,10 +163,6 @@ class _DdayRow extends StatelessWidget { ), ), ), - if (event.pinned) ...[ - const SizedBox(width: 6), - DkIcon('pin', size: 14, color: t.fgDisabled), - ], ], ), const SizedBox(height: 3), @@ -322,7 +186,12 @@ class _DdayRow extends StatelessWidget { fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.72, - color: info.urgency == DkUrgency.high ? t.danger : t.fgStrong, + // 종료(D+)는 강조 해제(회색), 임박은 danger, 그 외 기본. + color: switch (info.urgency) { + DkUrgency.high => t.danger, + DkUrgency.past => t.fgDisabled, + _ => t.fgStrong, + }, ), ), ], diff --git a/lib/theme/seed_icons.dart b/lib/theme/seed_icons.dart index 75889e0..5c14eba 100644 --- a/lib/theme/seed_icons.dart +++ b/lib/theme/seed_icons.dart @@ -1,13 +1,14 @@ // GENERATED — do not edit. Source: icons/lucide.json (run `npm run build`). /// Lucide 스타일 24-grid 아이콘 path 맵. flutter_svg 로 렌더. const Map kSeedIcons = { - 'home': 'M3 10.5 12 3l9 7.5 M5 9.5V21h14V9.5 M9.5 21v-6h5v6', + 'home': + 'M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8 M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', 'calendar': - 'M3 9h18 M7 3v3 M17 3v3 M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z', + 'M8 2v4 M16 2v4 M3 10h18 M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'carryForward': 'M21 11V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6 M3 9h18 M8 2v4 M16 2v4 M14 18h6 M20 18l-2.5-2.5 M20 18l-2.5 2.5', 'tasks': 'M4 6h2v2H4z M4 11h2v2H4z M4 16h2v2H4z M9 7h11 M9 12h11 M9 17h11', - 'focus': 'M12 22a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z M12 8v4l3 2 M9 2h6', + 'focus': 'M10 2h4 M12 14l3-3 M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z', 'settings': 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z M19.4 13.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-2.7-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.1-2.7l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 2.7-1.1V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8Z', 'bell': @@ -19,7 +20,7 @@ const Map kSeedIcons = { 'chevD': 'M6 9l6 6 6-6', 'clock': 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z M12 6v6l4 2', 'flame': - 'M12 2c1 4 5 5 5 9a5 5 0 0 1-10 0c0-2 1-3 1-3 .5 2 2 2 2 2 .5-3-.5-6 2-8Z', + 'M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5Z', 'arrowR': 'M5 12h14 M13 6l6 6-6 6', 'more': 'M6 12h.01 M12 12h.01 M18 12h.01', 'pin': 'M12 17v5 M9 3h6l-1 7 3 3H7l3-3-1-7Z', @@ -52,7 +53,7 @@ const Map kSeedIcons = { 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6 M18 9h1.5a2.5 2.5 0 0 0 0-5H18 M6 4h12v5a6 6 0 0 1-12 0V4Z M9 18h6 M10 18v-3 M14 18v-3 M8 21h8', 'alert': 'M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z', - 'chart': 'M3 3v18h18 M7 14l3-3 3 3 4-5', + 'chart': 'M3 3v16a2 2 0 0 0 2 2h16 M19 9l-5 5-4-4-3 3', 'sparkle': 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z', 'link': 'M9 15l6-6 M11 6l1-1a4 4 0 0 1 6 6l-1 1 M13 18l-1 1a4 4 0 0 1-6-6l1-1', diff --git a/lib/widgets/dk_bell_button.dart b/lib/widgets/dk_bell_button.dart new file mode 100644 index 0000000..9f1b557 --- /dev/null +++ b/lib/widgets/dk_bell_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/widgets.dart'; + +import '../theme/tokens.dart'; +import 'dk_icon.dart'; + +/// 우상단 알림 벨 버튼(모든 탭 공통). 프로토타입 `AppHeader` 의 벨을 단일 위젯으로 통일. +/// +/// 44×44 radius 14 bg-press, 벨 21 fg-muted. [unread]>0면 우상단 빨간 점(8, 2px 테두리). +/// 홈·플랜·통계·프로필이 모두 같은 위치/스타일을 쓰도록 여기로 일원화한다(중복 제거). +class DkBellButton extends StatelessWidget { + const DkBellButton({super.key, this.unread = 0, this.onTap}); + + /// 안 읽은 알림 수. 0 보다 크면 빨간 점을 표시한다. + final int unread; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final DkTokens t = DkTheme.of(context); + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: t.bgPress, + borderRadius: BorderRadius.circular(14), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + DkIcon('bell', size: 21, color: t.fgMuted), + if (unread > 0) + Positioned( + top: -2, + right: -1, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: t.danger, + shape: BoxShape.circle, + border: Border.all(color: t.bgPress, width: 2), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dk_icon.dart b/lib/widgets/dk_icon.dart index e7305f7..0e51ee9 100644 --- a/lib/widgets/dk_icon.dart +++ b/lib/widgets/dk_icon.dart @@ -7,15 +7,24 @@ import 'package:ieoseo/theme/seed_icons.dart'; /// 문자열(여러 서브패스가 ` M`으로 이어짐). const Map kDkIcons = kSeedIcons; -/// 단일 path `d` 문자열을 서브패스 단위로 쪼갠다(프로토타입 `d.split(" M")` 대응). -List _splitSubpaths(String d) { - final List raw = d.split(' M'); - return [ - for (int i = 0; i < raw.length; i++) i == 0 ? raw[i] : 'M${raw[i]}', - ]; -} +/// 단일 path `d` 문자열을 서브패스 단위로 쪼갠다. +/// +/// Lucide 아이콘은 여러 ``(각 스트로크)를 한 문자열로 평탄화해 두는데, 서브패스 시작은 +/// 대문자 `M`(절대)뿐 아니라 소문자 `m`(상대)일 수도 있다(예: `languages`=어학). 둘 다 경계로 +/// 쪼개 각자 `` 로 렌더한다 — 소문자 `m` 으로 시작하는 서브패스는 SVG 규약상 첫 moveto 가 +/// 절대로 취급돼 좌표가 맞는다. (대문자만 쪼개면 상대 `m` 서브패스가 앞 패스에 붙어 깨졌다.) +List _splitSubpaths(String d) => d.split(RegExp(r' (?=[Mm])')); + +/// 24-grid 둘레에 둘 여백(단위). 가장자리에 닿는 path(예: 캘린더 상단 고리 y=2, +/// sun 광선 등)는 굵은 stroke(활성 2.3)+round cap 이 viewBox 밖으로 삐져나가는데, +/// flutter_svg 는 viewBox 로 잘라내 아이콘이 잘려 보인다. viewBox 를 이만큼 넓혀 막는다. +const double _kIconViewBoxPad = 2; /// 색·두께·채움을 받아 stroke 아이콘 SVG 문자열을 만든다. +/// +/// viewBox 는 `0 0 24 24` 가 아니라 [_kIconViewBoxPad] 만큼 사방으로 넓힌다 → +/// 가장자리 stroke 가 잘리지 않는다(원본 아이콘 그대로 유지). 콘텐츠 크기 보정은 +/// [DkIcon.build] 가 그린 크기를 키워 맞춘다. String _buildSvg(String d, Color color, double strokeWidth, Color? fill) { final String stroke = _hex(color); final String fillAttr = fill == null ? 'none' : _hex(fill); @@ -23,7 +32,9 @@ String _buildSvg(String d, Color color, double strokeWidth, Color? fill) { for (final String p in _splitSubpaths(d)) { paths.write(''); } - return '$paths'; } @@ -63,10 +74,24 @@ class DkIcon extends StatelessWidget { Widget build(BuildContext context) { final String? d = kDkIcons[name]; if (d == null) return SizedBox(width: size, height: size); - return SvgPicture.string( - _buildSvg(d, color, strokeWidth, fill), + // viewBox 를 _kIconViewBoxPad 만큼 넓혔으므로, 24-grid 콘텐츠가 정확히 [size] 로 + // 보이도록 그린 그림은 그만큼 크게 그린다. 레이아웃 박스는 [size] 로 유지하고, + // 투명 여백은 OverflowBox 로 밖으로 흘려보낸다(가시 콘텐츠는 항상 size 안 → 잘림 없음). + final double drawn = size * (24 + 2 * _kIconViewBoxPad) / 24; + return SizedBox( width: size, height: size, + child: OverflowBox( + minWidth: 0, + minHeight: 0, + maxWidth: drawn, + maxHeight: drawn, + child: SvgPicture.string( + _buildSvg(d, color, strokeWidth, fill), + width: drawn, + height: drawn, + ), + ), ); } } diff --git a/lib/widgets/dk_tab_bar.dart b/lib/widgets/dk_tab_bar.dart index 8318b8e..b62b8ec 100644 --- a/lib/widgets/dk_tab_bar.dart +++ b/lib/widgets/dk_tab_bar.dart @@ -85,12 +85,14 @@ class DkTabBar extends StatelessWidget { curve: const Cubic(0.34, 1.3, 0.64, 1), child: Transform.translate( offset: Offset(0, on ? -1 : 0), + // 활성 탭도 채움(fill) 없이 아웃라인 형태를 유지한다 — primarySubtle 로 + // 채우면 집·달력·사람 아이콘이 파란 덩어리가 돼 원본 형태가 사라졌다. + // 강조는 primary 색 + 굵은 stroke + 확대(scale)로만 준다. child: DkIcon( spec.icon, size: 24, color: color, strokeWidth: on ? 2.3 : 1.9, - fill: on ? t.primarySubtle : null, ), ), ), @@ -139,7 +141,7 @@ class _CenterAddState extends State<_CenterAdd> { mainAxisSize: MainAxisSize.min, children: [ Transform.translate( - offset: const Offset(0, -8), + offset: const Offset(0, -4), child: AnimatedScale( scale: _down ? 0.94 : 1.0, duration: const Duration(milliseconds: 160), @@ -150,12 +152,11 @@ class _CenterAddState extends State<_CenterAdd> { decoration: BoxDecoration( color: t.primary, shape: BoxShape.circle, - border: Border.all(color: t.bg, width: 4), boxShadow: [ BoxShadow( - color: t.primary.withValues(alpha: 0.45), - offset: const Offset(0, 8), - blurRadius: 20, + color: t.fgStrong.withValues(alpha: 0.14), + offset: const Offset(0, 4), + blurRadius: 10, ), ], ), diff --git a/test/data_controller_test.dart b/test/data_controller_test.dart index 56573c8..ed7e6cd 100644 --- a/test/data_controller_test.dart +++ b/test/data_controller_test.dart @@ -128,6 +128,46 @@ void main() { }); }); + group('completeEvent / reopenEvent (종료 처리)', () { + const DkEvent e1 = DkEvent( + id: 'e1', + type: DkEventType.single, + title: '시험', + category: '자격증', + date: '2026-08-02', + ); + + test('종료 처리하면 completed=true 로 반영된다', () async { + final DataController c = DataController( + MockRepository( + tasks: [], + events: [e1], + debts: [], + ), + ); + await c.load(); + + await c.completeEvent('e1'); + + expect(c.events.single.completed, isTrue); + }); + + test('종료 취소하면 completed=false 로 되돌린다', () async { + final DataController c = DataController( + MockRepository( + tasks: [], + events: [e1.copyWith(completed: true)], + debts: [], + ), + ); + await c.load(); + + await c.reopenEvent('e1'); + + expect(c.events.single.completed, isFalse); + }); + }); + group('autoCarryDebt (F6 낙관·롤백)', () { test('낙관적으로 assigned 표시 후 실패 시 원래 상태로 롤백한다', () async { final DataController c = DataController(_FailingAutoCarryRepo()); diff --git a/test/dday_test.dart b/test/dday_test.dart index 86d96b5..1bba79d 100644 --- a/test/dday_test.dart +++ b/test/dday_test.dart @@ -90,6 +90,73 @@ void main() { }); }); + // 기간 D-Day 단계 전이(오늘 = 2026-06-10). 시작 전→시작당일→진행중→마감당일→종료. + // 임박도(urgency)는 "지금 표시 중인 경계" 기준: 시작 전=시작거리, 진행 중=마감거리, + // 종료=past(강조 해제). + group('기간 D-Day 단계 (오늘 = 2026-06-10)', () { + DkEvent period(String start, String end) => DkEvent( + id: 'e', + type: DkEventType.period, + title: '기간', + category: '자격증', + start: start, + end: end, + ); + + final DateTime today = DateTime(2026, 6, 10); + + test('시작 전 임박은 시작거리로 urgency 를 계산한다', () { + // 시작 D-2(임박), 마감은 멀다 → high 는 시작 기준이어야 한다. + final DkDdayInfo info = ddayInfo( + period('2026-06-12', '2026-06-30'), + today, + ); + expect(info.label, '시작 D-2'); + expect(info.status, '예정'); + expect(info.urgency, DkUrgency.high); + }); + + test('시작 당일은 "시작 D-DAY · high"', () { + final DkDdayInfo info = ddayInfo( + period('2026-06-10', '2026-06-20'), + today, + ); + expect(info.label, '시작 D-DAY'); + expect(info.status, '진행중'); + expect(info.urgency, DkUrgency.high); + }); + + test('진행 중은 "마감 D-N", 임박도는 마감거리 기준', () { + final DkDdayInfo info = ddayInfo( + period('2026-06-05', '2026-06-14'), + today, + ); + expect(info.label, '마감 D-4'); + expect(info.status, '진행중'); + expect(info.urgency, DkUrgency.mid); + }); + + test('마감 당일은 "마감 D-DAY · high"', () { + final DkDdayInfo info = ddayInfo( + period('2026-06-01', '2026-06-10'), + today, + ); + expect(info.label, '마감 D-DAY'); + expect(info.status, '진행중'); + expect(info.urgency, DkUrgency.high); + }); + + test('마감 다음 날부터 "마감 D+N · 종료 · past"(자동 삭제 아님)', () { + final DkDdayInfo info = ddayInfo( + period('2026-06-01', '2026-06-08'), + today, + ); + expect(info.label, '마감 D+2'); + expect(info.status, '종료'); + expect(info.urgency, DkUrgency.past); + }); + }); + group('urgencyOf', () { test('경계값 분류', () { expect(urgencyOf(-1), DkUrgency.past); diff --git a/test/main_scaffold_test.dart b/test/main_scaffold_test.dart index 4fd7286..23e6cc9 100644 --- a/test/main_scaffold_test.dart +++ b/test/main_scaffold_test.dart @@ -80,7 +80,13 @@ void main() { testWidgets('기본 탭은 오늘이고 TodayScreen을 보인다', (WidgetTester tester) async { await _pumpTall(tester); expect(find.byType(TodayScreen), findsOneWidget); - expect(find.text('안녕하세요, 지우님'), findsOneWidget); + // 홈 헤더는 오늘 날짜를 제목으로 노출한다(인사 카드 제거). + final DateTime now = DateTime.now(); + const List weekdays = ['월', '화', '수', '목', '금', '토', '일']; + expect( + find.text('${now.month}월 ${now.day}일 ${weekdays[now.weekday - 1]}요일'), + findsOneWidget, + ); }); testWidgets('탭바로 플랜·통계·프로필로 전환된다', (WidgetTester tester) async { @@ -100,6 +106,26 @@ void main() { expect(find.byType(MeScreen), findsOneWidget); }); + testWidgets('+ 버튼은 추가 시트를 열고 상단 탭으로 할 일↔D-Day 를 전환한다', ( + WidgetTester tester, + ) async { + await _pumpTall(tester); + + await tester.tap(find.byKey(const ValueKey('tabbar-add'))); + await tester.pumpAndSettle(); + + // 상단 세그먼트 [할 일 | D-Day 일정], 기본은 할 일 탭(태스크 폼 = 예상 소요시간). + expect(find.text('할 일'), findsWidgets); + expect(find.text('D-Day 일정'), findsOneWidget); + expect(find.text('예상 소요시간'), findsOneWidget); + + // D-Day 일정 탭으로 전환 → 이벤트 폼(이벤트 타입), 태스크 폼은 사라진다. + await tester.tap(find.text('D-Day 일정')); + await tester.pumpAndSettle(); + expect(find.text('이벤트 타입'), findsOneWidget); + expect(find.text('예상 소요시간'), findsNothing); + }); + testWidgets('미룬 시간 넛지 탭은 미룬 시간 상세 서브화면을 연다', (WidgetTester tester) async { await _pumpTall(tester); diff --git a/test/me_review_debt_test.dart b/test/me_review_debt_test.dart index 3781d16..6436ae6 100644 --- a/test/me_review_debt_test.dart +++ b/test/me_review_debt_test.dart @@ -7,6 +7,7 @@ import 'package:ieoseo/screens/debt/debt_screen.dart'; import 'package:ieoseo/screens/me/me_screen.dart'; import 'package:ieoseo/screens/me/settings_section.dart'; import 'package:ieoseo/screens/review/review_screen.dart'; +import 'package:ieoseo/widgets/dk_icon.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -260,6 +261,27 @@ void main() { expect(find.textContaining('수요일'), findsOneWidget); // insight }); + testWidgets('ReviewScreen은 onBell 제공 시 우상단 알림 벨을 보여주고 탭하면 호출한다', ( + WidgetTester tester, + ) async { + bool belled = false; + await _pumpTall( + tester, + ReviewScreen( + review: kWeekReview, + streak: kStreak, + onBell: () => belled = true, + ), + ); + + final Finder bell = find.byWidgetPredicate( + (Widget w) => w is DkIcon && w.name == 'bell', + ); + expect(bell, findsOneWidget); + await tester.tap(bell); + expect(belled, true); + }); + testWidgets('DebtScreen은 제목과 출처 라벨을 보여준다', (WidgetTester tester) async { await _pumpTall( tester, diff --git a/test/plan_test.dart b/test/plan_test.dart index 8d91859..7449f03 100644 --- a/test/plan_test.dart +++ b/test/plan_test.dart @@ -65,21 +65,23 @@ void main() { expect(find.text(label(prev.year, prev.month)), findsOneWidget); }); - testWidgets('일간 뷰: 오늘 칩 + 날짜 네비로 하루 이동', (WidgetTester tester) async { + testWidgets('주간 뷰: 오늘 칩 + 네비로 다음 주(+7일)로 이동', (WidgetTester tester) async { await _pumpTall(tester, _screen()); - await tester.tap(find.text('일간')); - await tester.pump(); - // 오늘 칩과 날짜 네비 화살표가 보인다(섹션헤드의 옛 오늘 버튼은 제거). + await tester.tap(find.text('주간')); + await tester.pumpAndSettle(); + // 일간 세그먼트는 제거됨(주간/월간만). + expect(find.text('일간'), findsNothing); + // 오늘 칩과 주 네비 화살표가 보인다. expect(find.text('오늘'), findsWidgets); expect(find.byKey(const ValueKey('calnav-next')), findsOneWidget); - // 다음 날 화살표 → 선택일이 내일로 이동(섹션헤드 날짜 갱신). - final DateTime tomorrow = addDays(kToday, 1); + // 다음 주 화살표 → 선택일이 +7일로 이동(섹션헤드 날짜 갱신, 슬라이드 후 정착). + final DateTime nextWeek = addDays(kToday, 7); await tester.tap(find.byKey(const ValueKey('calnav-next'))); - await tester.pump(); + await tester.pumpAndSettle(); expect( - find.textContaining('${tomorrow.month}월 ${tomorrow.day}일'), + find.textContaining('${nextWeek.month}월 ${nextWeek.day}일'), findsWidgets, ); }); diff --git a/test/sheets_test.dart b/test/sheets_test.dart index 59e57e1..98265cb 100644 --- a/test/sheets_test.dart +++ b/test/sheets_test.dart @@ -177,4 +177,53 @@ void main() { await tester.tap(find.textContaining('토익 시험이 3일')); expect(tapped?.id, 'n-1'); }); + + testWidgets('안 읽은 알림이 있으면 모두 읽음 액션을 노출하고 콜백을 호출한다', ( + WidgetTester tester, + ) async { + bool marked = false; + await _pumpTall( + tester, + NotifSheetBody( + items: const [ + DkNotif( + id: 'n-1', + type: DkNotifType.dday, + title: '토익 시험', + body: '토익 시험이 3일 남았어요', + read: false, + createdAt: '2026-06-04T09:00:00Z', + ), + ], + onTapItem: (_) {}, + onMarkAllRead: () => marked = true, + ), + ); + + expect(find.text('모두 읽음'), findsOneWidget); + await tester.tap(find.text('모두 읽음')); + expect(marked, true); + }); + + testWidgets('모두 읽은 상태면 모두 읽음 액션이 없다', (WidgetTester tester) async { + await _pumpTall( + tester, + NotifSheetBody( + items: const [ + DkNotif( + id: 'n-2', + type: DkNotifType.streak, + title: '스트릭 달성!', + body: '7일 연속 달성했어요', + read: true, + createdAt: '2026-06-03T09:00:00Z', + ), + ], + onTapItem: (_) {}, + onMarkAllRead: () {}, + ), + ); + + expect(find.text('모두 읽음'), findsNothing); + }); } diff --git a/test/today_logic_test.dart b/test/today_logic_test.dart index b0fc6f6..bd76f9b 100644 --- a/test/today_logic_test.dart +++ b/test/today_logic_test.dart @@ -111,19 +111,6 @@ void main() { ]); }); - test('고정(pin) 일정은 임박순보다 앞선다', () { - final List events = [ - ev('soon', '2026-06-03'), - ev('pinnedFar', '2026-06-25', pinned: true), - ]; - - final List out = ddayOrdered( - events, - today: DateTime(2026, 6, 1), - ); - expect(out.first.id, 'pinnedFar'); - }); - test('지난 일정은 미래 일정보다 뒤로 간다', () { final List events = [ ev('past', '2026-05-20'), // 지남 diff --git a/test/today_test.dart b/test/today_test.dart index acf677e..95b2b40 100644 --- a/test/today_test.dart +++ b/test/today_test.dart @@ -12,12 +12,10 @@ TodayScreen _screen({ VoidCallback? onOpenDebt, }) { return TodayScreen( - userName: '지우', events: events ?? kEvents, debts: kDebts, onOpenEvent: onOpenEvent ?? (_) {}, onBell: () {}, - onOpenCalc: () {}, onOpenDebt: onOpenDebt ?? () {}, ); } @@ -33,13 +31,19 @@ Future _pumpTall(WidgetTester tester, Widget child) async { } void main() { - testWidgets('인사 헤더·다가오는 일정·미룬시간 넛지를 렌더한다', (WidgetTester tester) async { + testWidgets('헤더(오늘 날짜)·다가오는 일정·미룬시간 넛지를 렌더한다', (WidgetTester tester) async { await _pumpTall(tester, _screen()); - expect(find.text('안녕하세요, 지우님'), findsOneWidget); + // 상단 좌측에 오늘 날짜를 제목으로 노출한다(ink 인사 카드는 제거됨). + final DateTime now = DateTime.now(); + const List weekdays = ['월', '화', '수', '목', '금', '토', '일']; + final String dateLabel = + '${now.month}월 ${now.day}일 ${weekdays[now.weekday - 1]}요일'; + expect(find.text(dateLabel), findsOneWidget); expect(find.text('다가오는 일정'), findsOneWidget); expect(find.text('미룬 시간'), findsOneWidget); - // 오늘 할 일 콕핏·아젠다는 제거됨(플랜>할 일로 일원화). + // 옛 인사 카드·오늘 할 일 콕핏은 제거됨. + expect(find.textContaining('안녕하세요'), findsNothing); expect(find.text('오늘의 흐름'), findsNothing); }); @@ -65,4 +69,26 @@ void main() { await tester.tap(find.text('미룬 시간')); expect(opened, true); }); + + testWidgets('종료(완료) 처리한 이벤트는 홈에서 숨긴다', (WidgetTester tester) async { + const DkEvent done = DkEvent( + id: 'done', + type: DkEventType.single, + title: '끝난 일정', + category: '자격증', + date: '2026-12-01', + completed: true, + ); + const DkEvent live = DkEvent( + id: 'live', + type: DkEventType.single, + title: '남은 일정', + category: '자격증', + date: '2026-12-02', + ); + await _pumpTall(tester, _screen(events: [done, live])); + + expect(find.text('끝난 일정'), findsNothing); + expect(find.text('남은 일정'), findsOneWidget); + }); }