10 Commits

Author SHA1 Message Date
ttipo 5331362215 docs(rules): 서버화에 맞춰 규칙·스킬 문서 갱신
구현한 구조를 규칙 SSOT 에 반영한다 — 다음 작업이 같은 패턴을 쓰도록.

- security: 모바일 리프레시 토큰은 secure storage(쿠키 저장소가 없다) — 대신 해시 저장·회전·재사용 탐지.
  소셜 토큰은 서버가 제공자에게 확인, 개발용 로그인은 운영에서 코드로 닫힘, 업로드 매직 바이트 검사·소유자 확인
- nestjs 스킬: 인증·회원·도메인 모듈·커서 헬퍼·공통 검증·snake_case 이름 규칙·아이 삭제 정리 리스너,
  새 라우트는 기본 로그인 필요(@Public 예외)·새 엔티티는 소유자 조건 조회
- flutter 스킬: DATA_MODE, 세션·토큰 갱신, 실패 알림·succeeded(), 서버 사진, 저장소 계약(Local·Api) 패턴
- env·응답 형식·에러 코드·테스트·체크리스트: DATA_MODE·소셜 키, keysetPaginate·fetchAllPages,
  도메인 코드 목록, 앱의 AUTH_002 흐름, 가짜 서버·라이브 점검 테스트
- vue3 스킬: 웹 로그인 연동 시 필요한 것 (지금 인증은 모바일용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:43:48 +09:00
ttipo 373d50576a test(flutter): 서버 모드 화면 흐름 테스트
기존 흐름 테스트는 기기 저장 모드로 돈다. 가짜 서버를 붙여 서버 모드에서도
로그인 → 온보딩 → 홈(서버의 아이) 흐름, 세션 만료 시 로그인 화면 복귀(AUTH_002),
로그인 전에는 서버에 묻지 않는 것을 화면 단위로 확인한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:40:36 +09:00
ttipo b9c351d7cf fix(flutter): 라이브 점검의 테스트 전용 API 경고 정리
test_live/ 는 분석기가 테스트 폴더로 보지 않아 setMockInitialValues 가 경고로 잡혔다.
CI 의 flutter analyze 는 경고 0 을 요구하므로 이유를 적고 이 줄만 예외로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:37:30 +09:00
ttipo 42abefcc66 test(flutter): 서버 계약 라이브 점검 추가
단위 테스트는 응답 모양을 가정한 가짜 서버로 돈다. 그 가정이 실제 서버와 맞는지
개발 서버에 앱의 서버 저장소를 그대로 붙여 확인한다 (로그인 → 프로필 → 아이 → 일정 → 다이어리 → 명함 →
보관·삭제 때 서버 정리 → 탈퇴).

서버가 필요하므로 test/ 가 아닌 test_live/ 에 두어 CI 의 flutter test 에서는 돌지 않는다.
  cd flutter && flutter test test_live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:36:33 +09:00
ttipo 9cb660a0ac fix(flutter): 추억 보관함·순서 바꾸기도 서버 실패를 견디게
서버에 저장하면서 실패할 수 있게 된 남은 흐름을 정리했다.

- 보관하기·다시 불러오기는 성공했을 때만 화면을 옮기고 토스트를 띄운다
- 우리 아이 순서 바꾸기는 화면에 먼저 반영하고, 저장하지 못하면 원래 순서로 되돌린다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:11 +09:00
ttipo 2c1d1cc97d feat(flutter): 펫 명함을 서버에 저장 (ApiPetCardRepository)
- 명함 목록·만들기·꾸미기·삭제·대표 명함을 /pet-cards, /pets/:id/representative-card 로 옮겼다
- 명함과 대표 지정을 한 번에 불러오도록 저장소가 PetCardBook 을 돌려준다
- 배경은 앱 그림(assets/…)이면 경로만, 기기 사진이면 올린 뒤 파일 id 로 보낸다
- 공유 화면은 서버가 발급한 명함 코드를 보여 준다 (기기 저장 모드는 지금처럼 명함 id 로 만든다)
- 명함을 지울 때 대표 지정 정리, 아이를 지울 때 명함 정리는 저장소가 맡는다 — 서버 모드는 서버가 이미 했다
- 명함 화면은 실패하면 꾸민 모양을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:32:38 +09:00
ttipo a09086daf7 feat(flutter): 다이어리를 서버에 저장 (ApiDiaryRepository)
다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다.

- 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다
- 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다.
  로그인할 때 회원 id 를 기기에 둔다
- 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다
- 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:20 +09:00
ttipo 60efc65ee0 feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)
일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다.

- 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기.
  돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다
- 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다).
  ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다
- 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다.
  아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다
- 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:23:55 +09:00
ttipo 100dbe3a35 feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)
- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다
- 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다
- 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다
- 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다
- DATA_MODE=local 이면 지금까지처럼 기기에 저장한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:17:43 +09:00
ttipo 00660c7d62 feat(flutter): 로그인·프로필·탈퇴를 서버에 연결
- 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다.
  기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다
- 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤
  기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다
- 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다
- 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다
- 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다
- 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다)
- 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:14:33 +09:00
68 changed files with 3088 additions and 410 deletions
+3 -1
View File
@@ -10,7 +10,9 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
소셜 로그인을 쓰려면 제공자 콘솔 값(`KAKAO_APP_ID` 등)도 채운다 — 비어 있으면 그 제공자는 거절하고, 개발에서는 개발용 로그인을 쓴다
- [ ] DB 스키마 적용 — `dc exec backend npm run migration:run` ([[nestjs]] "DB 마이그레이션")
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL`·`DATA_MODE` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
+6 -2
View File
@@ -32,12 +32,16 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL`·`DATA_MODE` 를 독립적으로 관리한다.
`DATA_MODE=api`(기본)는 서버에 저장하고, `local` 은 서버 없이 기기에 저장한다(화면 확인용). `.env` 를 읽지 않는 테스트는 `local` 로 돈다.
- 백엔드 소셜 로그인 값(`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`)과 `AUTH_DEV_LOGIN` 은
루트에서 내려주지 않는 백엔드 고유 값이라 `backend/.env.{APP_ENV}` 에 둔다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`/`JWT_SECRET`(32자 이상) 필수
- 토큰 만료 기간(`JWT_ACCESS_EXPIRES_IN`·`JWT_REFRESH_EXPIRES_IN`)은 `15m`·`1h`·`14d` 형식 (모든 환경)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
+11 -1
View File
@@ -38,13 +38,23 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
### 도메인 코드 (현재)
| 코드 | HTTP | 상황 |
| --- | --- | --- |
| FILE_001 / FILE_002 | 400 / 413 | 허용되지 않는 파일 형식(내용 검사 포함) / 크기 초과 |
| DIARY_001 | 409 | 다이어리 책장이 가득 참 (최대 6권 — 추억 보관함의 아이만 담은 다이어리는 세지 않는다) |
| DIARY_002 | 409 | 한 사람이 한 다이어리에 하루 한 번 |
| CARD_001 | 409 | 펫 명함 최대 10장 |
### AUTH_002 (클라이언트 전용)
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
- 앱: `dio_client.dart` — 401 이면 먼저 리프레시 토큰으로 갱신해 다시 보내고, **서버가 갱신을 거절했을 때만** AUTH_002 + 세션 삭제
(`SessionStore.clear(expired: true)` → 진입 상태가 로그인 화면으로). 갱신하려다 서버에 닿지 못했으면 세션을 두고 SYS_001.
## 도메인 코드 추가
+4 -2
View File
@@ -71,7 +71,8 @@ exportCsv(@Res() res: Response) { /* ... */ }
- 구현 SSOT: backend `common/dto/pagination.dto.ts`
(`CursorPaginationQueryDto` · `OffsetPaginationQueryDto` · `PageInfoDto` · `toPaginatedResult()`)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `UserRepository.getUsers()` 패턴
- 구현 헬퍼: backend `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` (커서 = 정렬 키를 담은 불투명 문자열)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `core/api/api_paging.dart` 의 `fetchAllPages()`
- **커서 기반이 기본**이다. 전체 페이지 수가 UI 에 필요한 경우에만 오프셋을 쓴다.
- `limit` 기본값 20, 최대 100 — 무제한 조회를 허용하지 않는다.
@@ -81,10 +82,11 @@ exportCsv(@Res() res: Response) { /* ... */ }
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo }
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
```
- `ApiStandardResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiStandardResponse` / `ApiPaginatedResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiErrorResponse` 는 상태 코드·메시지를 에러 레지스트리에서 가져오므로 문서가 구현과 자동으로 일치한다.
> 에러 코드 ↔ 메시지 매핑은 [[error-codes]], API 설계 규약은 [[api-design]] 참조.
+9 -6
View File
@@ -12,7 +12,11 @@
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 웹이면 HttpOnly 쿠키로만 전달
- **모바일 앱은 예외** — 앱에는 쿠키 저장소가 없어 리프레시 토큰을 응답 본문으로 받아 `flutter_secure_storage`(키: `refresh_token`)에 둔다.
대신 서버는 리프레시 토큰을 **해시로만 저장**하고, **한 번 쓰면 폐기(회전)** 하며, 폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (`modules/auth`)
- 소셜 로그인은 앱이 보낸 사용자 id 를 믿지 않는다 — 서버가 토큰을 **제공자에게 직접 확인**한다 (카카오는 앱 ID 까지 대조)
- 개발용 로그인(`/auth/dev-sessions`)은 운영에서 **코드로 닫힌다** — env 값으로 열 수 없다
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
@@ -26,7 +30,8 @@
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. 저장한 뒤 **파일 앞부분(매직 바이트)도 확인**해 형식과 다르면 지운다.
- 파일은 **올린 회원의 것**으로 기록하고, 내려받기(`GET /files/:id`)·다른 기록에서의 참조 모두 본인 파일만 허용한다.
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
@@ -35,10 +40,8 @@
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
> ⚠️ **알려진 한계**: 매직 바이트는 파일의 **앞부분만** 본다. 앞은 PNG 이고 뒤에 다른 내용을 덧붙인 파일은 통과한다.
> 그래서 위의 "정적 서빙 금지"(attachment + 서버가 정한 Content-Type + nosniff)가 여전히 중요하다.
## 네트워크 · 인프라
+4 -2
View File
@@ -51,14 +51,16 @@ describe('formatSomething', () => {
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 (개발용 로그인으로 회원을 만들고 끝나면 탈퇴) |
| 앱 ↔ 서버 응답 모양 | `flutter/test/api_*_test.dart` + `test/support/fake_api.dart` | 앱의 서버 저장소가 보내고 읽는 JSON 모양 (가짜 서버) |
| 앱 ↔ 서버 라이브 점검 | `flutter/test_live/api_contract_live_test.dart` | 위 가정이 **실제 개발 서버**와 맞는지 — 서버가 필요해 CI 에서는 돌지 않는다 (`flutter test test_live`) |
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다. `NODE_ENV=test` 에서는 기동할 때 마이그레이션을 적용한다(빈 DB).
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
+35 -30
View File
@@ -16,18 +16,23 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| 데이터 모드 | `lib/core/config/app_config.dart` | `.env` 의 `DATA_MODE` — `api`(기본, 서버) / `local`(기기 저장 · 서버 없이 화면 확인 · 테스트) |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` + `dio_provider.dart` | Provider 로 주입(`ref.watch(dioProvider)`), baseUrl=dotenv `API_URL`, 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(**401 → 토큰 갱신 후 한 번 재시도**·코드 매핑) — 인라인 |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
| 실패 알림 | `apiErrorReporterProvider` → `rup_toast.dart` 의 `apiErrorToastReporter` | 사전 문구 토스트, 같은 코드는 잠깐 한 번만. 호출부가 처리하는 코드는 `kSilentCodesExtra` 로 끈다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
| 세션 | `lib/core/api/session_store.dart` | 액세스·리프레시 토큰(secure storage `kJwtTokenKey`·`kRefreshTokenKey`)을 들고 있다. 회원이 바뀌면 `sessionEpochProvider` 가 오른다 |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 갱신을 서버가 거절하면 `AUTH_002` + 세션 삭제 → 진입 상태가 로그인 화면으로 보낸다 |
| 화면의 실패 처리 | `lib/core/api/api_guard.dart` | `succeeded(future)` / `isReportedApiFailure(e)` — 알림은 이미 떴으니 화면은 멈추기만 한다 |
| 목록 | `lib/core/api/api_paging.dart` | `fetchAllPages()` — `{items, pageInfo}` 를 마지막 페이지까지 이어 읽는다 |
| 사진 | `server_file.dart` · `file_uploader.dart` · `shared/widgets/photo_image.dart` | 사진 경로 자리에 `server-file:<id>`. 기기 사진은 저장할 때 내용으로 형식을 확인해 올린다. 서버 사진은 Dio 로 받아 그린다 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | 세션(토큰)을 첫 프레임 전에 읽어 override 한다 |
| 도메인 feature(패턴 참고) | `lib/features/pet/` | 저장소 **계약 + `Local*` + `Api*`**, 프로바이더가 `DATA_MODE` 로 고른다. 알림기 build 는 `sessionEpochProvider` 를 watch |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
> - **상태관리**: `features/pet` 의 저장소 계약(Local·Api)→알림기 패턴을 복제. 서버가 이미 한 정리(아이 삭제에 따른 일정 정리 등)는 Api 저장소에서 아무것도 보내지 않는다
> - **소셜 로그인 SDK**: 앱 키를 받으면 `ApiAuthRepository.signIn` 만 SDK 토큰 → `POST /auth/sessions` 로 바꾼다 (지금은 기기 키로 개발용 로그인)
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
@@ -162,38 +167,33 @@ class UserWidget extends ConsumerWidget {
## API 통신 (Dio + 인터셉터)
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
실제 구현(`lib/core/api/dio_client.dart`)은 `InterceptorsWrapper` 인라인 방식이고, `dio_provider.dart` 가 세션·실패 알림을 넣어 만든다.
인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
```dart
// core/api/dio_client.dart (실제 구조 요약)
class DioClient {
DioClient._internal() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
DioClient({required String baseUrl, required SessionStore session, ApiErrorReporter? onError}) {
_dio = Dio(BaseOptions(baseUrl: baseUrl, connectTimeout: ..., receiveTimeout: ...));
_authDio = Dio(...); // 갱신 전용 — 인터셉터 없음
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
onRequest: (options, handler) {
// 세션의 액세스 토큰을 Authorization 헤더에 붙인다 (토큰은 SessionStore 가 secure storage 에서 읽어 둔다)
},
onResponse: (response, handler) {
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
// success==true → data 언래핑, false → ApiException 으로 reject (+ 알림)
},
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
// 401 이면 리프레시 토큰으로 갱신해 한 번 다시 보낸다 (동시에 여러 요청이 와도 갱신은 한 번)
// 그래도 실패하면 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 서버가 갱신을 거절하면 AUTH_002(세션 만료) + 세션 삭제, 서버에 닿지 못했으면 SYS_001
},
));
}
}
// 리포지토리는 ref.watch(dioProvider) 로 주입받는다
final dioProvider = Provider<Dio>((ref) => ref.watch(dioClientProvider).dio);
```
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
@@ -206,7 +206,8 @@ class DioClient {
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
// 저장 (키 이름은 session_store 의 상수로 통일: kJwtTokenKey == 'jwt_token', kRefreshTokenKey == 'refresh_token')
// 앱 코드는 직접 쓰지 않고 SessionStore(SecureTokenVault)가 맡는다
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
@@ -242,9 +243,12 @@ await storage.deleteAll();
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
- [ ] **새 저장소**: 계약 + `Local*`(기기) + `Api*`(서버), 프로바이더가 `DATA_MODE` 로 고른다. 로그인 전에는 서버를 부르지 않는다. `test/api_*_test.dart` 처럼 가짜 서버(`test/support/fake_api.dart`)로 테스트한다.
- [ ] **화면 실패 처리**: 서버 호출은 `succeeded()` 또는 `catch (e) { if (!isReportedApiFailure(e)) rethrow; }` — 입력을 지운 채 넘어가지 않는다.
- [ ] **사진**: 화면은 `PetPhotoImage`/`PersonAvatar`(→ `PhotoImage`)로 그린다 — 서버 파일·기기 파일을 같은 자리에서 처리한다.
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
- [ ] **목록**: 응답은 `{items, pageInfo}` — `fetchAllPages()` 재사용 ([[response-format]]).
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
@@ -253,5 +257,6 @@ await storage.deleteAll();
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL`·`DATA_MODE` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
- [ ] **서버 계약 점검**: 서버 쪽 응답 모양을 바꿨다면 개발 서버를 띄우고 `flutter test test_live` (CI 에서는 돌지 않는다).
+30 -14
View File
@@ -31,18 +31,24 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| **인증(JWT)** | `modules/auth` + `common/guards/jwt-auth.guard.ts` (APP_GUARD) | **모든 라우트가 기본으로 로그인 필요** — 공개 라우트만 `@Public()`. 핸들러는 `@CurrentUser()` 로 회원을 받는다. 아래 "인증" 참조 |
| 회원 | `modules/user` | 내 정보 조회·수정·탈퇴(`/users/me`), 소셜 계정 연결 |
| 도메인 모듈(패턴 참고) | `modules/pet` · `schedule` · `diary` · `pet-card` | 새 모듈은 `modules/pet` 을 복제한다 (엔티티·DTO·서비스·컨트롤러·소유자 조회) |
| 커서 페이지네이션 헬퍼 | `common/dto/keyset-pagination.ts` | `keysetPaginate(qb, 정렬키, query)` + `mapPage()`. 목록 Swagger 는 `@ApiPaginatedResponse` |
| 공통 검증 데코레이터 | `common/validators` | `IsIsoDate`·`IsHexColor`·`IsHourMinute`·`IsTargetIdList`·`IsShortStringList` |
| 컬럼 이름 규칙 | `shared/config/snake-naming.strategy.ts` | 엔티티 속성 camelCase ↔ 컬럼 snake_case (database.config.ts 에 연결) |
| 도메인 간 정리 | `modules/pet/pet-lifecycle.ts` | 아이 삭제·보관을 따라 일정·다이어리·명함이 같은 트랜잭션에서 정리 (`onModuleInit` 에서 등록) |
| 파일 업로드 | `modules/upload` | 소유자 기록·매직 바이트 검사·`GET /files/:id`(본인만, attachment)·`releaseIfUnused()` ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> - **소셜 로그인 운영**: 제공자 콘솔의 값을 `backend/.env.{env}` 에 넣는다 (`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`). 비어 있는 제공자는 로그인을 거절한다
> - **새 엔티티**: 소유자 컬럼(`owner_id`, 회원 삭제 시 CASCADE)을 두고 **조회는 항상 소유자 조건**으로 — 남의 것과 없는 것은 똑같이 `RES_001`.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **파일을 참조하는 컬럼**: FK 는 `onDelete: 'NO ACTION'` 으로 둔다 — `UploadService.releaseIfUnused()` 가 이 제약으로 "아직 쓰는 파일"을 가린다
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
@@ -181,6 +187,7 @@ SwaggerModule.setup('api-docs', app, document);
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo } 로 그린다
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
@@ -190,12 +197,20 @@ SwaggerModule.setup('api-docs', app, document);
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
`modules/auth` 가 구현되어 있다. 경로는 리소스 이름으로 둔다 ([[api-design]] — 동사 금지).
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
| 경로 | 하는 일 |
| --- | --- |
| `POST /auth/sessions` | 소셜 로그인(처음이면 가입) — 앱이 SDK 로 받은 토큰을 **서버가 제공자에게 직접 확인**한다 |
| `POST /auth/dev-sessions` | 개발용 로그인(기기 키) — 소셜 앱 키 전까지. **운영에서는 닫힌다**(RES_001) |
| `POST /auth/tokens` | 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍 (회전) |
| `POST /auth/token-revocation` | 로그아웃 — 리프레시 토큰 폐기 (204) |
- Access Token: 1시간(`JWT_ACCESS_EXPIRES_IN`), HS256. 페이로드 `{ sub, typ: 'access' }`
- Refresh Token: 불투명 랜덤 값, **DB 에는 SHA-256 해시만**. 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다
- 모바일은 리프레시 토큰을 응답 본문으로 받아 secure storage 에 둔다 (웹을 붙이면 HttpOnly 쿠키로 내려주는 경로를 따로 둔다 — [[security]])
- 전역 `JwtAuthGuard` — 토큰이 없거나 틀렸거나 탈퇴한 회원이면 원인을 가리지 않고 AUTH_001
- 로그인 엔드포인트는 전역보다 엄격한 rate limit(분당 10회)을 건다
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
@@ -217,7 +232,7 @@ SwaggerModule.setup('api-docs', app, document);
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]]). Swagger 는 `@ApiPaginatedResponse(Dto)`
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
@@ -270,6 +285,7 @@ dc exec backend npm run migration:run
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
테스트 환경(`NODE_ENV=test`)도 자동 실행한다 — CI 의 e2e 는 빈 DB 에서 시작하기 때문이다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
@@ -317,6 +333,6 @@ dc exec backend npm uninstall <패키지>
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **새 라우트**: 기본으로 로그인이 필요하다 — 공개해야 하는 라우트만 `@Public()`. 회원은 `@CurrentUser()` 로 받는다.
- [ ] **새 엔티티**: 소유자 조건으로만 조회(남의 것 = RES_001), 마이그레이션 생성·검토, 파일 FK 는 NO ACTION.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+3 -1
View File
@@ -36,7 +36,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 연동**: 백엔드 인증(`modules/auth`)은 지금 **모바일용**이다 — 소셜 토큰으로 `POST /auth/sessions`, 토큰은 응답 본문.
> 웹을 붙일 때는 리프레시 토큰을 HttpOnly 쿠키로 내려주는 경로를 백엔드에 추가한 뒤 `useAuth` 를 연결하고,
> 성공 시 `authStore.markAuthenticated()` 를 호출한다 ([[security]]). `useAuth`·`useUser` 는 아직 템플릿 예제 경로를 가리킨다 (내 정보는 `GET /users/me`)
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
+3
View File
@@ -10,3 +10,6 @@
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
API_URL=http://localhost:3000/api
# 기록을 어디에 둘지 — api: 서버(기본, 로그인한 회원의 기록) / local: 이 기기 (서버 없이 화면만 볼 때)
DATA_MODE=api
+25
View File
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
/// 서버 요청을 기다리고 성공했는지만 돌려준다.
/// 실패 알림은 인터셉터(토스트)가 이미 띄웠으므로 화면은 메시지를 새로 만들지 않고 멈추기만 한다 (.claude/rules/error-codes.md).
/// if (!await succeeded(notifier.save(input))) return;
Future<bool> succeeded(Future<void> action) async {
try {
await action;
return true;
} on DioException catch (e) {
if (e.error is ApiException) return false;
rethrow;
} on ApiException {
return false;
}
}
/// 이미 알린 서버 실패인지 — 화면의 catch 에서 이 실패만 삼키고 나머지는 다시 던진다.
/// } catch (e) {
/// if (!isReportedApiFailure(e)) rethrow;
/// }
bool isReportedApiFailure(Object error) =>
error is ApiException || (error is DioException && error.error is ApiException);
+21
View File
@@ -112,3 +112,24 @@ class SessionStore extends ChangeNotifier {
/// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트).
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore(MemoryTokenVault()));
/// 로그인한 회원이 바뀐 횟수 — 로그인·로그아웃·세션 만료 때 1씩 오른다 (토큰 갱신은 같은 회원이라 그대로).
/// 서버 기록을 들고 있는 알림기는 build 에서 이 값을 watch 해, 회원이 바뀌면 다시 불러온다.
class SessionEpochNotifier extends Notifier<int> {
@override
int build() {
final session = ref.watch(sessionStoreProvider);
var signedIn = session.hasSession;
void onChange() {
if (session.hasSession == signedIn) return;
signedIn = session.hasSession;
state = state + 1;
}
session.addListener(onChange);
ref.onDispose(() => session.removeListener(onChange));
return 0;
}
}
final sessionEpochProvider = NotifierProvider<SessionEpochNotifier, int>(SessionEpochNotifier.new);
@@ -37,7 +37,7 @@ class ArchivedPetPage extends ConsumerWidget {
final String petId;
Future<void> _restore(BuildContext context, WidgetRef ref) async {
await ref.read(archiveActionsProvider).restore(petId);
if (!await ref.read(archiveActionsProvider).restore(petId)) return;
if (context.mounted) context.go(AppRoutes.archive);
}
@@ -78,11 +78,11 @@ class _PetArchiveMovePageState extends ConsumerState<PetArchiveMovePage> {
Future<void> _submit(Pet pet, DateTime from) async {
final memo = _memo.text.trim();
await ref.read(archiveActionsProvider).archive(
final moved = await ref.read(archiveActionsProvider).archive(
pet.id,
PetArchive(from: from, to: _to, archivedAt: DateTime.now(), memo: memo.isEmpty ? null : memo),
);
if (mounted) context.go(AppRoutes.myPage);
if (moved && mounted) context.go(AppRoutes.myPage);
}
@override
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
@@ -11,17 +12,20 @@ class ArchiveActions {
final Ref _ref;
/// 아이를 보관함으로 옮긴다. 일정은 지우고(여러 아이 일정이면 그 아이만 뗀다),
/// 다이어리·펫 명함·정보는 그대로 두어 보관함에서 다시 볼 수 있게 한다.
Future<void> archive(String petId, PetArchive archive) async {
await _ref.read(petListProvider.notifier).archivePet(petId, archive);
/// 다이어리·펫 명함·정보는 그대로 두어 보관함에서 다시 볼 수 있게 한다 (서버 모드는 일정 정리를 서버가 한다).
/// 옮기지 못하면 false — 실패 알림은 인터셉터가 띄웠다.
Future<bool> archive(String petId, PetArchive archive) async {
if (!await succeeded(_ref.read(petListProvider.notifier).archivePet(petId, archive))) return false;
await _ref.read(scheduleProvider.notifier).dropTarget(petId);
_ref.read(toastProvider.notifier).show('추억보관함에 보관했어요.', tone: ToastTone.memory);
return true;
}
/// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다
Future<void> restore(String petId) async {
await _ref.read(petListProvider.notifier).restorePet(petId);
/// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다. 불러오지 못하면 false
Future<bool> restore(String petId) async {
if (!await succeeded(_ref.read(petListProvider.notifier).restorePet(petId))) return false;
_ref.read(toastProvider.notifier).show('다시 불러왔어요');
return true;
}
}
@@ -0,0 +1,101 @@
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_client.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
/// 로그인한 소셜 계정 종류·이름 — 화면에 바로 쓰려고 기기에 함께 둔다 (토큰은 secure storage)
const String _kProviderKey = 'auth.provider';
const String _kNicknameKey = 'auth.nickname';
/// 로그인한 회원 id — 서버 기록의 작성자가 "나"인지 가릴 때 쓴다 (다이어리 기록·댓글)
const String kAuthUserIdKey = 'auth.userId';
/// 서버 인증 경로 (backend AuthController)
const String _kDevSessionsPath = '/auth/dev-sessions';
const String _kRevocationPath = '/auth/token-revocation';
const String _kMePath = '/users/me';
/// 서버 로그인 — 토큰은 [SessionStore] 가 들고, 소셜 계정 종류·이름은 기기에 둔다.
///
/// 지금은 소셜 앱 키가 없어 **개발용 로그인**(기기 키)으로 가입·로그인한다 (운영 서버는 이 경로를 닫는다).
/// 소셜 SDK 를 붙이면 SDK 로 받은 토큰을 `POST /auth/sessions {provider, token}` 으로 보내도록
/// [signIn] 만 바꾸면 된다 — 응답 모양(토큰 + user)은 같다.
class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({
required Dio dio,
required SessionStore session,
required SharedPreferences prefs,
required DeviceKeyStore deviceKeys,
}) : _dio = dio,
_session = session,
_prefs = prefs,
_deviceKeys = deviceKeys;
final Dio _dio;
final SessionStore _session;
final SharedPreferences _prefs;
final DeviceKeyStore _deviceKeys;
@override
bool get hasSession => _session.hasSession;
@override
String? get nickname => _session.hasSession ? _prefs.getString(_kNicknameKey) : null;
@override
SocialProvider? get provider =>
_session.hasSession ? SocialProvider.values.asNameMap()[_prefs.getString(_kProviderKey)] : null;
@override
Future<void> signIn(SocialProvider provider) async {
final deviceKey = await _deviceKeys.readOrCreate();
final response = await _dio.post<Map<String, dynamic>>(
_kDevSessionsPath,
data: {'provider': provider.name, 'deviceKey': deviceKey},
);
final data = response.data!;
final user = data['user']! as Map<String, dynamic>;
await _prefs.setString(kAuthUserIdKey, user['id']! as String);
await _prefs.setString(_kProviderKey, (user['provider'] as String?) ?? provider.name);
final name = user['nickname'] as String?;
name == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, name);
// 세션을 마지막에 저장한다 — 저장하는 순간 진입 상태가 다음 화면으로 넘어간다
await _session.save(AuthTokens.fromJson(data));
}
/// 로그아웃 — 서버의 리프레시 토큰을 폐기하고 세션을 지운다.
/// 폐기 요청이 실패해도(네트워크 등) 이 기기에서는 로그아웃한다 — 토큰은 어차피 만료된다.
@override
Future<void> signOut() async {
final refresh = _session.tokens?.refreshToken;
if (refresh != null) {
try {
await _dio.post<void>(
_kRevocationPath,
data: {'refreshToken': refresh},
options: Options(extra: {kSilentExtra: true}),
);
} on DioException {
// 기기에서는 그대로 로그아웃한다
}
}
await _forget();
}
@override
Future<void> deleteAccount() async {
await _dio.delete<void>(_kMePath);
await _forget();
}
Future<void> _forget() async {
await _prefs.remove(kAuthUserIdKey);
await _prefs.remove(_kProviderKey);
await _prefs.remove(_kNicknameKey);
await _session.clear();
}
}
@@ -1,11 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
/// 로그인 세션 관리 계약.
/// 실제 구현은 소셜 SDK 로 받은 토큰을 백엔드 인증 API 에 넘기고,
/// 발급받은 JWT 를 flutter_secure_storage(kJwtTokenKey)에 저장한다.
/// 서버 구현(ApiAuthRepository)은 로그인으로 받은 토큰을 flutter_secure_storage(kJwtTokenKey)에 저장한다.
abstract interface class AuthRepository {
/// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다)
bool get hasSession;
@@ -19,12 +23,14 @@ abstract interface class AuthRepository {
Future<void> signIn(SocialProvider provider);
Future<void> signOut();
/// 회원탈퇴 — 서버의 회원과 모든 기록을 지우고 세션을 끝낸다 (기기 저장 모드는 할 일이 없다)
Future<void> deleteAccount();
}
const String _kDemoSessionKey = 'auth.demoSession';
/// 임시 구현 — 백엔드 인증 API 와 소셜 SDK(앱 키 필요)가 준비되기 전까지
/// 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다.
/// 기기 저장 모드 — 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다.
class DemoAuthRepository implements AuthRepository {
DemoAuthRepository(this._prefs);
@@ -50,8 +56,20 @@ class DemoAuthRepository implements AuthRepository {
Future<void> signOut() async {
await _prefs.remove(_kDemoSessionKey);
}
@override
Future<void> deleteAccount() async {}
}
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return DemoAuthRepository(ref.watch(sharedPreferencesProvider));
final prefs = ref.watch(sharedPreferencesProvider);
return switch (ref.watch(dataModeProvider)) {
DataMode.local => DemoAuthRepository(prefs),
DataMode.api => ApiAuthRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
prefs: prefs,
deviceKeys: ref.watch(deviceKeyStoreProvider),
),
};
});
@@ -0,0 +1,51 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 기기 키 저장 키 — 개발용 로그인의 계정 구분값
const String kDeviceKeyKey = 'device_key';
/// 기기 키 길이 (바이트) — base64url 로 32자. 서버는 16~128자를 받는다
const int _kDeviceKeyBytes = 24;
/// 개발용 로그인의 기기 키 — 소셜 앱 키가 준비되기 전, 소셜 계정 대신 이 기기를 한 회원으로 본다.
/// 같은 키로 다시 로그인하면 같은 회원이므로, 로그인 수단처럼 secure storage 에 둔다.
abstract interface class DeviceKeyStore {
/// 저장된 키 — 없으면 새로 만들어 저장한다
Future<String> readOrCreate();
}
class SecureDeviceKeyStore implements DeviceKeyStore {
const SecureDeviceKeyStore([this._storage = const FlutterSecureStorage()]);
final FlutterSecureStorage _storage;
@override
Future<String> readOrCreate() async {
final saved = await _storage.read(key: kDeviceKeyKey);
if (saved != null) return saved;
final key = newDeviceKey(Random.secure());
await _storage.write(key: kDeviceKeyKey, value: key);
return key;
}
}
/// 테스트용 — 정해진 키를 돌려준다
class FixedDeviceKeyStore implements DeviceKeyStore {
const FixedDeviceKeyStore(this.key);
final String key;
@override
Future<String> readOrCreate() async => key;
}
/// 추측할 수 없는 기기 키 (base64url, 패딩 없음)
String newDeviceKey(Random random) {
final bytes = List<int>.generate(_kDeviceKeyBytes, (_) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
final deviceKeyStoreProvider = Provider<DeviceKeyStore>((ref) => const SecureDeviceKeyStore());
@@ -0,0 +1,184 @@
import 'dart:ui' show Color;
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/dio_client.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/diary/data/diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController)
const String _kDiariesPath = '/diaries';
const String _kPostsPath = '/diary-posts';
const String _kCommentsPath = '/diary-comments';
/// 책장이 가득 찼을 때 서버가 주는 코드 — 앱은 null 로 받아 화면이 직접 안내한다
const String kDiaryFullCode = 'DIARY_001';
Diary diaryFromApi(Map<String, Object?> json) => Diary(
id: json['id']! as String,
name: json['name']! as String,
targetIds: (json['targetIds'] as List?)?.whereType<String>().toList() ?? const [],
ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first,
tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first,
coverPath: serverFileRefOrNull(json['coverFileId'] as String?),
mutedNotices: {
for (final n in (json['mutedNotices'] as List?)?.whereType<String>() ?? const <String>[])
?DiaryNotice.values.where((v) => v.name == n).firstOrNull,
},
);
/// 서버 저장소 — 사진은 저장할 때 올린다.
/// 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기를 가린다).
class ApiDiaryRepository implements DiaryRepository {
ApiDiaryRepository({
required Dio dio,
required SessionStore session,
required FileUploader uploader,
required String? Function() myUserId,
}) : _dio = dio,
_session = session,
_uploader = uploader,
_myUserId = myUserId;
final Dio _dio;
final SessionStore _session;
final FileUploader _uploader;
final String? Function() _myUserId;
/// 지금은 다이어리 주인만 쓰므로, 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다
String _author(Object? id) {
final me = _myUserId();
return me == null || id == me ? kMeAuthorId : id! as String;
}
DiaryPost _post(Map<String, Object?> json) => DiaryPost(
id: json['id']! as String,
diaryId: json['diaryId']! as String,
date: parseIsoDate(json['date']! as String),
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
text: json['text'] as String? ?? '',
mood: Mood.byName(json['mood'] as String?),
photoPath: serverFileRefOrNull(json['photoFileId'] as String?),
authorId: _author(json['authorId']),
);
DiaryComment _comment(Map<String, Object?> json) => DiaryComment(
id: json['id']! as String,
postId: json['postId']! as String,
text: json['text'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
authorId: _author(json['authorId']),
);
@override
Future<DiaryBook> fetch() async {
if (!_session.hasSession) return DiaryBook.empty;
return DiaryBook(
diaries: await fetchAllPages(_dio, _kDiariesPath, diaryFromApi),
posts: await fetchAllPages(_dio, _kPostsPath, _post),
comments: await fetchAllPages(_dio, _kCommentsPath, _comment),
);
}
@override
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
}) async {
final coverFileId = await _uploader.ensureUploaded(coverPath);
try {
final response = await _dio.post<Map<String, dynamic>>(
_kDiariesPath,
data: {
'name': name,
'targetIds': targetIds,
'ribbon': hexOf(ribbon),
'tint': hexOf(tint),
'coverFileId': coverFileId,
},
// 가득 찬 경우는 화면이 "최대 6개" 로 직접 안내한다
options: Options(extra: {
kSilentCodesExtra: [kDiaryFullCode],
}),
);
return diaryFromApi(response.data!);
} on DioException catch (e) {
final error = e.error;
if (error is ApiException && error.code == kDiaryFullCode) return null;
rethrow;
}
}
@override
Future<Diary> updateDiary(Diary diary) async {
final response = await _dio.patch<Map<String, dynamic>>(
'$_kDiariesPath/${diary.id}',
data: {
'name': diary.name,
'targetIds': diary.targetIds,
'ribbon': hexOf(diary.ribbon),
'coverFileId': await _uploader.ensureUploaded(diary.coverPath),
'mutedNotices': [for (final n in diary.mutedNotices) n.name],
},
);
return diaryFromApi(response.data!);
}
@override
Future<void> deleteDiary(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId');
@override
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
}) async {
final photoFileId = await _uploader.ensureUploaded(photoPath);
final response = await _dio.post<Map<String, dynamic>>(
'$_kDiariesPath/$diaryId/posts',
data: {'date': toIsoDate(date), 'text': text, 'mood': mood?.name, 'photoFileId': photoFileId},
);
return _post(response.data!);
}
@override
Future<DiaryPost> updatePostText(DiaryPost post, String text) async {
final response = await _dio.patch<Map<String, dynamic>>('$_kPostsPath/${post.id}', data: {'text': text});
return _post(response.data!);
}
@override
Future<void> deletePost(String postId) => _dio.delete<void>('$_kPostsPath/$postId');
@override
Future<DiaryComment> addComment(String postId, String text) async {
final response = await _dio.post<Map<String, dynamic>>('$_kPostsPath/$postId/comments', data: {'text': text});
return _comment(response.data!);
}
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async {
final response = await _dio.patch<Map<String, dynamic>>('$_kCommentsPath/${comment.id}', data: {'text': text});
return _comment(response.data!);
}
@override
Future<void> deleteComment(String commentId) => _dio.delete<void>('$_kCommentsPath/$commentId');
/// 서버가 아이를 지우면서 이미 다이어리 대상에서 뗐다
@override
Future<void> dropTarget(String targetId) async {}
}
@@ -1,16 +1,62 @@
import 'dart:convert';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
/// 다이어리 저장소 계약 — 공유 다이어리(서버)가 생기면 API 구현으로 바꾼다.
/// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository).
/// 바꾼 결과(id 가 붙은 다이어리·기록·댓글)를 돌려주면 알림기가 [DiaryBook] 에 반영한다.
abstract interface class DiaryRepository {
Future<DiaryBook> fetch();
Future<void> save(DiaryBook book);
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001)
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
});
/// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다)
Future<Diary> updateDiary(Diary diary);
/// 다이어리와 그 안의 기록·댓글을 모두 지운다
Future<void> deleteDiary(String diaryId);
/// 기록 남기기 — [date] 는 오늘(기기 날짜). 하루 한 번
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
});
Future<DiaryPost> updatePostText(DiaryPost post, String text);
/// 기록과 그 댓글을 지운다
Future<void> deletePost(String postId);
Future<DiaryComment> addComment(String postId, String text);
Future<DiaryComment> updateComment(DiaryComment comment, String text);
Future<void> deleteComment(String commentId);
/// 아이를 지우면 다이어리 대상에서만 뗀다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 이미 정리했다.
Future<void> dropTarget(String targetId);
}
const String _kDiariesKey = 'diary.diaries.v1';
@@ -20,27 +66,120 @@ const String _kCommentsKey = 'diary.comments.v1';
List<Map<String, Object?>> _decode(String? raw) =>
raw == null ? const [] : (jsonDecode(raw) as List<Object?>).whereType<Map<String, Object?>>().toList();
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트).
class LocalDiaryRepository implements DiaryRepository {
LocalDiaryRepository(this._prefs);
LocalDiaryRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
int _serial = 0;
@override
Future<DiaryBook> fetch() async => DiaryBook(
String _newId(String prefix) => '$prefix${_clock().microsecondsSinceEpoch}_${_serial++}';
DiaryBook _read() => DiaryBook(
diaries: _decode(_prefs.getString(_kDiariesKey)).map(Diary.fromJson).toList(),
posts: _decode(_prefs.getString(_kPostsKey)).map(DiaryPost.fromJson).toList(),
comments: _decode(_prefs.getString(_kCommentsKey)).map(DiaryComment.fromJson).toList(),
);
@override
Future<void> save(DiaryBook book) async {
Future<void> _write(DiaryBook book) async {
await _prefs.setString(_kDiariesKey, jsonEncode([for (final d in book.diaries) d.toJson()]));
await _prefs.setString(_kPostsKey, jsonEncode([for (final p in book.posts) p.toJson()]));
await _prefs.setString(_kCommentsKey, jsonEncode([for (final c in book.comments) c.toJson()]));
}
@override
Future<DiaryBook> fetch() async => _read();
/// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다
@override
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
}) async {
final diary = Diary(
id: _newId('d'),
name: name,
targetIds: targetIds,
ribbon: ribbon,
tint: tint,
coverPath: coverPath,
);
await _write(_read().addDiary(diary));
return diary;
}
@override
Future<Diary> updateDiary(Diary diary) async {
await _write(_read().updateDiary(diary));
return diary;
}
@override
Future<void> deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId));
@override
Future<DiaryPost> addPost({
required String diaryId,
required DateTime date,
required String text,
Mood? mood,
String? photoPath,
}) async {
final post = DiaryPost(
id: _newId('p'),
diaryId: diaryId,
date: date,
createdAt: _clock(),
text: text,
mood: mood,
photoPath: photoPath,
);
await _write(_read().addPost(post));
return post;
}
@override
Future<DiaryPost> updatePostText(DiaryPost post, String text) async {
await _write(_read().updatePostText(post.id, text));
return post.copyWith(text: text);
}
@override
Future<void> deletePost(String postId) async => _write(_read().deletePost(postId));
@override
Future<DiaryComment> addComment(String postId, String text) async {
final comment = DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: _clock());
await _write(_read().addComment(comment));
return comment;
}
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async {
await _write(_read().updateComment(comment.id, text));
return comment.copyWith(text: text);
}
@override
Future<void> deleteComment(String commentId) async => _write(_read().deleteComment(commentId));
@override
Future<void> dropTarget(String targetId) async => _write(_read().dropTarget(targetId));
}
final diaryRepositoryProvider = Provider<DiaryRepository>((ref) {
return LocalDiaryRepository(ref.watch(sharedPreferencesProvider));
final prefs = ref.watch(sharedPreferencesProvider);
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalDiaryRepository(prefs),
DataMode.api => ApiDiaryRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
uploader: ref.watch(fileUploaderProvider),
myUserId: () => prefs.getString(kAuthUserIdKey),
),
};
});
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
@@ -70,6 +71,9 @@ class _DiaryComposePageState extends ConsumerState<DiaryComposePage> {
toast.show('기록을 남겼어요');
// 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다
if (mounted) context.pop(true);
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 쓴 기록을 그대로 두고 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -48,7 +49,7 @@ class DiarySettingsPage extends ConsumerWidget {
// 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
context.go(AppRoutes.records);
await ref.read(diaryProvider.notifier).deleteDiary(diary.id);
if (!await succeeded(ref.read(diaryProvider.notifier).deleteDiary(diary.id))) return;
ref.read(toastProvider.notifier).show('다이어리를 제거했어요');
}
@@ -144,13 +145,13 @@ class DiarySettingsPage extends ConsumerWidget {
HeartToggle(
value: diary.notifies(row.notice),
label: row.title,
onChanged: (on) => unawaited(ref.read(diaryProvider.notifier).updateDiary(
onChanged: (on) => unawaited(succeeded(ref.read(diaryProvider.notifier).updateDiary(
diary.copyWith(
mutedNotices: on
? ({...diary.mutedNotices}..remove(row.notice))
: {...diary.mutedNotices, row.notice},
),
)),
))),
),
],
),
@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/diary/data/diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
@@ -10,19 +11,20 @@ import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
/// 다이어리 · 기록 · 댓글
/// 저장은 저장소가 건별로 하고, 돌려받은 결과를 [DiaryBook] 에 반영한다.
class DiaryNotifier extends AsyncNotifier<DiaryBook> {
DiaryRepository get _repo => ref.read(diaryRepositoryProvider);
int _serial = 0;
@override
Future<DiaryBook> build() => ref.watch(diaryRepositoryProvider).fetch();
Future<DiaryBook> build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return ref.watch(diaryRepositoryProvider).fetch();
}
DiaryBook get _book => state.value ?? DiaryBook.empty;
String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}';
Future<void> _commit(DiaryBook next) async {
await _repo.save(next);
void _set(DiaryBook next) {
if (ref.mounted) state = AsyncData(next);
}
@@ -34,49 +36,67 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
String? coverPath,
}) async {
if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null;
final diary = Diary(
id: _newId('d'),
final diary = await _repo.createDiary(
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
targetIds: targetIds,
ribbon: ribbon,
tint: kDiaryTints[Random().nextInt(kDiaryTints.length)],
coverPath: coverPath,
);
await _commit(_book.addDiary(diary));
if (diary != null) _set(_book.addDiary(diary));
return diary;
}
Future<void> updateDiary(Diary diary) => _commit(_book.updateDiary(diary));
Future<void> updateDiary(Diary diary) async => _set(_book.updateDiary(await _repo.updateDiary(diary)));
Future<void> deleteDiary(String id) => _commit(_book.deleteDiary(id));
Future<void> deleteDiary(String id) async {
await _repo.deleteDiary(id);
_set(_book.deleteDiary(id));
}
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) {
final now = DateTime.now();
return _commit(_book.addPost(DiaryPost(
id: _newId('p'),
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) async {
final post = await _repo.addPost(
diaryId: diaryId,
date: dateOnly(now),
createdAt: now,
date: dateOnly(DateTime.now()),
text: text,
mood: mood,
photoPath: photoPath,
)));
);
_set(_book.addPost(post));
}
Future<void> updatePostText(String id, String text) => _commit(_book.updatePostText(id, text));
Future<void> updatePostText(String id, String text) async {
final post = _book.posts.where((p) => p.id == id).firstOrNull;
if (post == null) return;
final saved = await _repo.updatePostText(post, text);
_set(_book.updatePostText(id, saved.text));
}
Future<void> deletePost(String id) => _commit(_book.deletePost(id));
Future<void> deletePost(String id) async {
await _repo.deletePost(id);
_set(_book.deletePost(id));
}
Future<void> addComment(String postId, String text) => _commit(_book.addComment(
DiaryComment(id: _newId('c'), postId: postId, text: text, createdAt: DateTime.now()),
));
Future<void> addComment(String postId, String text) async =>
_set(_book.addComment(await _repo.addComment(postId, text)));
Future<void> updateComment(String id, String text) => _commit(_book.updateComment(id, text));
Future<void> updateComment(String id, String text) async {
final comment = _book.comments.where((c) => c.id == id).firstOrNull;
if (comment == null) return;
final saved = await _repo.updateComment(comment, text);
_set(_book.updateComment(id, saved.text));
}
Future<void> deleteComment(String id) => _commit(_book.deleteComment(id));
Future<void> deleteComment(String id) async {
await _repo.deleteComment(id);
_set(_book.deleteComment(id));
}
/// 아이를 지우면 다이어리 대상에서만 뗀다
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
/// 아이를 지우면 다이어리 대상에서만 뗀다 (서버 모드는 서버가 이미 정리했다)
Future<void> dropTarget(String targetId) async {
await _repo.dropTarget(targetId);
_set(_book.dropTarget(targetId));
}
}
final diaryProvider = AsyncNotifierProvider<DiaryNotifier, DiaryBook>(DiaryNotifier.new);
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
@@ -294,8 +295,9 @@ class _CommentsState extends ConsumerState<_Comments> {
Future<void> _add() async {
final text = _draft.text.trim();
if (text.isEmpty) return;
_draft.clear();
await ref.read(diaryProvider.notifier).addComment(widget.postId, text);
// 저장하지 못하면 쓴 댓글을 그대로 둔다 (실패 알림은 인터셉터가 띄웠다)
if (!await succeeded(ref.read(diaryProvider.notifier).addComment(widget.postId, text))) return;
if (mounted && _draft.text.trim() == text) _draft.clear();
}
Future<void> _saveEdit() async {
@@ -303,7 +305,7 @@ class _CommentsState extends ConsumerState<_Comments> {
final text = _edit.text.trim();
setState(() => _editingId = null);
if (id == null || text.isEmpty) return;
await ref.read(diaryProvider.notifier).updateComment(id, text);
await succeeded(ref.read(diaryProvider.notifier).updateComment(id, text));
}
Future<void> _delete(DiaryComment c) async {
@@ -315,7 +317,7 @@ class _CommentsState extends ConsumerState<_Comments> {
layout: RupConfirmLayout.row,
tightTop: true,
);
if (ok) await ref.read(diaryProvider.notifier).deleteComment(c.id);
if (ok) await succeeded(ref.read(diaryProvider.notifier).deleteComment(c.id));
}
@override
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
@@ -216,6 +217,9 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
toast.show('다이어리 정보를 수정했어요');
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 시트를 열어 둔 채 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
@@ -17,7 +18,7 @@ Future<void> managePost(BuildContext context, WidgetRef ref, DiaryPost post) asy
case PostAction.edit:
final text = await showEditPostSheet(context, post.text);
if (text == null) return;
await notifier.updatePostText(post.id, text);
if (!await succeeded(notifier.updatePostText(post.id, text))) return;
toast.show('기록을 수정했어요');
case PostAction.delete:
final ok = await showRupConfirm(
@@ -30,7 +31,7 @@ Future<void> managePost(BuildContext context, WidgetRef ref, DiaryPost post) asy
iconColor: kDangerTone,
);
if (!ok) return;
await notifier.deletePost(post.id);
if (!await succeeded(notifier.deletePost(post.id))) return;
toast.show('기록을 삭제했어요');
}
}
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
import 'package:tma_app/features/entry/data/entry_repository.dart';
@@ -11,6 +12,18 @@ class EntryNotifier extends Notifier<EntryState> {
@override
EntryState build() {
// 서버가 세션을 끊으면(토큰 갱신 거절 — AUTH_002) 로그인 화면으로 돌아간다.
// 안내 토스트는 인터셉터가 이미 띄웠다. 로그아웃처럼 인트로를 거치지 않는다.
final session = ref.watch(sessionStoreProvider);
void onSession() {
if (session.expired && state.signedIn) {
state = state.copyWith(signedIn: false, introPassed: true);
}
}
session.addListener(onSession);
ref.onDispose(() => session.removeListener(onSession));
return EntryState(
introPassed: false,
signedIn: ref.watch(authRepositoryProvider).hasSession,
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -57,7 +58,7 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
selectedId: selected?.id,
onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id),
// 꾹 눌러 끌어서 우리 아이 순서를 바꾼다
onReorder: (ids) => unawaited(ref.read(petListProvider.notifier).reorder(ids)),
onReorder: (ids) => unawaited(succeeded(ref.read(petListProvider.notifier).reorder(ids))),
),
if (selected != null) ...[
const SizedBox(height: 20),
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -108,7 +109,7 @@ class HomeScheduleSection extends ConsumerWidget {
done: book.isDone(t.id),
missed: review && !book.isDone(t.id),
first: i == 0,
onTap: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(t.id)),
onTap: () => unawaited(succeeded(ref.read(scheduleProvider.notifier).toggleDone(t.id))),
),
if (hidden > 0) _MoreRow(count: hidden, onTap: () => openSchedule(context, ref, date: today)),
],
@@ -1,44 +1,176 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
const String _kNicknameKey = 'profile.nickname';
const String _kPhotoKey = 'profile.photo';
/// 서버 모드에서 마지막으로 받은 내 프로필 — 앱을 열자마자 보여 주려고 기기에 둔다
const String _kApiNicknameKey = 'profile.api.nickname';
const String _kApiPhotoKey = 'profile.api.photo';
/// 내 정보 경로 (backend UserController)
const String _kMePath = '/users/me';
/// 내 프로필 — 앱에서 바꾼 닉네임·사진 (없으면 소셜 계정 값 / 기본값을 쓴다)
@immutable
class UserProfile {
const UserProfile({this.nickname, this.photoPath});
final String? nickname;
/// 기기 사진 경로 또는 서버 파일(`server-file:<id>`)
final String? photoPath;
@override
bool operator ==(Object other) =>
other is UserProfile && other.nickname == nickname && other.photoPath == photoPath;
@override
int get hashCode => Object.hash(nickname, photoPath);
}
/// 프로필 저장 — 서버 회원 API 가 생기면 그쪽으로 옮긴다
class ProfileRepository {
ProfileRepository(this._prefs);
/// 프로필 저장소 계약
abstract interface class ProfileRepository {
/// 화면에 바로 쓰는 값 — 기기에 둔 값 (서버 모드는 마지막으로 받은 값)
UserProfile load();
/// 최신 값 — 서버 모드는 서버에서 받는다
Future<UserProfile> fetch();
/// 저장하고 저장된 값을 돌려준다
Future<UserProfile> save(UserProfile profile);
/// 로그아웃할 때 — 서버 모드는 기기에 둔 프로필을 지운다 (다음에 로그인할 회원에게 보이지 않게)
Future<void> forget();
}
Future<void> _writeOrRemove(SharedPreferences prefs, String key, String? value) =>
value == null ? prefs.remove(key) : prefs.setString(key, value);
/// 기기 저장 모드 — 프로필을 이 기기에만 둔다
class LocalProfileRepository implements ProfileRepository {
LocalProfileRepository(this._prefs);
final SharedPreferences _prefs;
@override
UserProfile load() => UserProfile(
nickname: _prefs.getString(_kNicknameKey),
photoPath: _prefs.getString(_kPhotoKey),
);
Future<void> save(UserProfile profile) async {
final nickname = profile.nickname;
final photo = profile.photoPath;
nickname == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, nickname);
photo == null ? await _prefs.remove(_kPhotoKey) : await _prefs.setString(_kPhotoKey, photo);
@override
Future<UserProfile> fetch() async => load();
@override
Future<UserProfile> save(UserProfile profile) async {
await _writeOrRemove(_prefs, _kNicknameKey, profile.nickname);
await _writeOrRemove(_prefs, _kPhotoKey, profile.photoPath);
return profile;
}
/// 로그아웃해도 기록은 기기에 남는다
@override
Future<void> forget() async {}
}
/// 서버 모드 — GET/PATCH /users/me. 사진은 저장할 때 올린다
class ApiProfileRepository implements ProfileRepository {
ApiProfileRepository({
required Dio dio,
required SessionStore session,
required FileUploader uploader,
required SharedPreferences prefs,
}) : _dio = dio,
_session = session,
_uploader = uploader,
_prefs = prefs;
final Dio _dio;
final SessionStore _session;
final FileUploader _uploader;
final SharedPreferences _prefs;
@override
UserProfile load() => UserProfile(
nickname: _prefs.getString(_kApiNicknameKey),
photoPath: _prefs.getString(_kApiPhotoKey),
);
@override
Future<UserProfile> fetch() async {
// 로그인 전에는 부르지 않는다 (로그인 화면에서 "로그인이 필요" 알림이 뜨지 않게)
if (!_session.hasSession) return load();
final response = await _dio.get<Map<String, dynamic>>(_kMePath);
return _remember(response.data!);
}
@override
Future<UserProfile> save(UserProfile profile) async {
final photoFileId = await _uploader.ensureUploaded(profile.photoPath);
final response = await _dio.patch<Map<String, dynamic>>(
_kMePath,
data: {'nickname': ?profile.nickname, 'photoFileId': photoFileId},
);
return _remember(response.data!);
}
@override
Future<void> forget() async {
await _prefs.remove(_kApiNicknameKey);
await _prefs.remove(_kApiPhotoKey);
}
Future<UserProfile> _remember(Map<String, dynamic> me) async {
final profile = UserProfile(
nickname: me['nickname'] as String?,
photoPath: serverFileRefOrNull(me['photoFileId'] as String?),
);
await _writeOrRemove(_prefs, _kApiNicknameKey, profile.nickname);
await _writeOrRemove(_prefs, _kApiPhotoKey, profile.photoPath);
return profile;
}
}
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
return ProfileRepository(ref.watch(sharedPreferencesProvider));
final prefs = ref.watch(sharedPreferencesProvider);
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalProfileRepository(prefs),
DataMode.api => ApiProfileRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
uploader: ref.watch(fileUploaderProvider),
prefs: prefs,
),
};
});
class ProfileNotifier extends Notifier<UserProfile> {
@override
UserProfile build() => ref.watch(profileRepositoryProvider).load();
UserProfile build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
final repo = ref.watch(profileRepositoryProvider);
// 기기에 둔 값으로 먼저 그리고, 최신 값이 오면 바꾼다 — 실패 알림은 인터셉터가 띄운다
unawaited(succeeded(_refresh(repo)));
return repo.load();
}
Future<void> _refresh(ProfileRepository repo) async {
final latest = await repo.fetch();
if (ref.mounted) state = latest;
}
/// 넘긴 값만 바꾼다 (null 이면 기존 값 유지)
Future<void> update({String? nickname, String? photoPath}) async {
@@ -46,8 +178,8 @@ class ProfileNotifier extends Notifier<UserProfile> {
nickname: nickname ?? state.nickname,
photoPath: photoPath ?? state.photoPath,
);
await ref.read(profileRepositoryProvider).save(next);
state = next;
final saved = await ref.read(profileRepositoryProvider).save(next);
if (ref.mounted) state = saved;
}
}
@@ -1,5 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
@@ -17,12 +19,18 @@ class AccountActions {
final Ref _ref;
/// 로그아웃 — 기록은 기기에 남는다. 라우터가 로그인 화면으로 보낸다
Future<void> logout() => _ref.read(entryProvider.notifier).signOut();
/// 로그아웃 — 기록은 서버(서버 모드) 또는 기기(기기 저장 모드)에 남는다. 라우터가 로그인 화면으로 보낸다.
/// 서버 모드는 다음에 로그인할 회원에게 보이지 않게 기기에 둔 프로필을 지운다.
Future<void> logout() async {
await _ref.read(entryProvider.notifier).signOut();
await _ref.read(profileRepositoryProvider).forget();
}
/// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다.
/// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다.
/// 회원탈퇴 — 서버의 회원과 기록을 지우고(서버 모드), 이 기기에 둔 기록·사진·설정도 모두 지운 뒤 처음(인트로)으로 돌아간다.
/// 서버 탈퇴가 실패하면(알림은 인터셉터가 띄운다) 기기의 기록도 그대로 둔다.
Future<void> withdraw() async {
if (!await succeeded(_ref.read(authRepositoryProvider).deleteAccount())) return;
// 기록 삭제와 화면 전환이 먼저다 — 사진 폴더 정리가 늦거나 실패해도 탈퇴는 끝나야 한다
final photos = _ref.read(petPhotoStoreProvider);
await _ref.read(sharedPreferencesProvider).clear();
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
@@ -68,10 +69,12 @@ class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
Future<void> _submit() async {
final name = _name.text.trim();
// 비워 두면 닉네임은 바꾸지 않는다 (시안: 값이 있을 때만 반영)
await ref.read(profileProvider.notifier).update(
final saved = await succeeded(ref.read(profileProvider.notifier).update(
nickname: name.isEmpty ? null : name,
photoPath: _photoPath,
);
));
// 실패 알림은 인터셉터가 띄웠다 — 시트를 열어 둔 채 다시 시도할 수 있게 한다
if (!saved) return;
ref.read(toastProvider.notifier).show('프로필을 수정했어요', icon: RupIconName.check);
if (mounted) Navigator.of(context).pop();
}
@@ -0,0 +1,129 @@
import 'dart:ui' show Color;
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 반려동물 경로 (backend PetController)
const String _kPetsPath = '/pets';
/// 서버 응답(PetResponseDto) → 앱의 Pet
Pet petFromApi(Map<String, Object?> json) {
final archive = json['archive'] as Map<String, Object?>?;
return Pet(
id: json['id']! as String,
tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
name: json['name']! as String,
species: json['species']! as String,
breed: json['breed'] as String? ?? '',
gender: PetGender.values.byName(json['gender']! as String),
neutered: json['neutered']! as bool,
birthDate: parseIsoDate(json['birthDate']! as String),
weight: (json['weight']! as num).toDouble(),
weightUnit: WeightUnit.values.byName(json['weightUnit']! as String),
photoPath: serverFileRefOrNull(json['photoFileId'] as String?),
microchip: json['microchip'] as String?,
bloodType: json['bloodType'] as String?,
vet: json['vet'] as String?,
memo: json['memo'] as String?,
allergies: _strings(json['allergies']),
conditions: _strings(json['conditions']),
watchConditions: _strings(json['watchConditions']),
archive: archive == null
? null
: PetArchive(
from: parseIsoDate(archive['from']! as String),
to: parseIsoDate(archive['to']! as String),
archivedAt: DateTime.parse(archive['archivedAt']! as String).toLocal(),
memo: archive['memo'] as String?,
),
);
}
List<String> _strings(Object? value) => value is List ? value.whereType<String>().toList() : const [];
/// 앱의 PetInput → 서버 요청 본문 (사진은 미리 올린 파일 id)
Map<String, Object?> petInputToApi(PetInput input, {required String? photoFileId}) => {
'name': input.name,
'species': input.species,
'breed': input.breed,
'gender': input.gender.name,
'neutered': input.neutered,
'birthDate': toIsoDate(input.birthDate),
'weight': input.weight,
'weightUnit': input.weightUnit.name,
'photoFileId': photoFileId,
'microchip': input.microchip,
'bloodType': input.bloodType,
'vet': input.vet,
'memo': input.memo,
'allergies': input.allergies,
'conditions': input.conditions,
'watchConditions': input.watchConditions,
};
/// 서버 저장소 — 아이 사진은 저장할 때 올린다. 아이를 지우거나 보관하면 명함·일정·다이어리는 서버가 함께 정리한다.
class ApiPetRepository implements PetRepository {
ApiPetRepository({required Dio dio, required SessionStore session, required FileUploader uploader})
: _dio = dio,
_session = session,
_uploader = uploader;
final Dio _dio;
final SessionStore _session;
final FileUploader _uploader;
@override
Future<List<Pet>> fetchPets() async {
// 로그인 전에는 부르지 않는다
if (!_session.hasSession) return const [];
return fetchAllPages(_dio, _kPetsPath, petFromApi);
}
@override
Future<Pet> create(PetInput input, {required Color tint}) async {
final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath));
final response = await _dio.post<Map<String, dynamic>>(_kPetsPath, data: {...body, 'tint': hexOf(tint)});
return petFromApi(response.data!);
}
@override
Future<Pet> update(String id, PetInput input) async {
final body = petInputToApi(input, photoFileId: await _uploader.ensureUploaded(input.photoPath));
final response = await _dio.patch<Map<String, dynamic>>('$_kPetsPath/$id', data: body);
return petFromApi(response.data!);
}
@override
Future<void> delete(String id) => _dio.delete<void>('$_kPetsPath/$id');
/// 보관한 때(archivedAt)는 서버가 정한다
@override
Future<Pet> archive(String id, PetArchive archive) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kPetsPath/$id/archive',
data: {'from': toIsoDate(archive.from), 'to': toIsoDate(archive.to), 'memo': archive.memo},
);
return petFromApi(response.data!);
}
@override
Future<Pet> restore(String id) async {
final response = await _dio.delete<Map<String, dynamic>>('$_kPetsPath/$id/archive');
return petFromApi(response.data!);
}
/// 순서를 저장하고 서버가 정한 순서로 다시 읽는다
@override
Future<List<Pet>> reorder(List<String> ids) async {
await _dio.put<void>('$_kPetsPath/order', data: {'ids': ids});
return fetchPets();
}
}
@@ -1,17 +1,24 @@
import 'dart:convert';
import 'dart:ui' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다.
/// 반려동물 저장소 계약 — 서버(ApiPetRepository) / 기기(LocalPetRepository)
abstract interface class PetRepository {
/// 사용자가 정한 순서대로
Future<List<Pet>> fetchPets();
Future<Pet> create(PetInput input);
/// [tint] 는 앱이 등록 순서대로 정한 아이의 고유색 (petTintFor)
Future<Pet> create(PetInput input, {required Color tint});
Future<Pet> update(String id, PetInput input);
@@ -29,8 +36,7 @@ abstract interface class PetRepository {
const String _kPetsKey = 'pets.v1';
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 서버 연동 전까지 앱을 다시 켜도 등록한 아이가 남도록 하기 위한 것이다.
/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트).
class LocalPetRepository implements PetRepository {
LocalPetRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
@@ -51,13 +57,9 @@ class LocalPetRepository implements PetRepository {
Future<List<Pet>> fetchPets() async => _read();
@override
Future<Pet> create(PetInput input) async {
Future<Pet> create(PetInput input, {required Color tint}) async {
final pets = _read();
final pet = Pet.fromInput(
'pet-${_clock().microsecondsSinceEpoch}',
input,
tint: petTintFor(pets.length),
);
final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input, tint: tint);
await _write([...pets, pet]);
return pet;
}
@@ -108,5 +110,12 @@ List<Pet> reorderPets(List<Pet> pets, List<String> ids) {
}
final petRepositoryProvider = Provider<PetRepository>((ref) {
return LocalPetRepository(ref.watch(sharedPreferencesProvider));
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalPetRepository(ref.watch(sharedPreferencesProvider)),
DataMode.api => ApiPetRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
uploader: ref.watch(fileUploaderProvider),
),
};
});
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -250,6 +251,9 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
// 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다
if (mounted) context.pop();
}
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 입력한 값을 그대로 두고 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -266,8 +270,8 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
destructive: true,
);
if (!ok || !mounted) return;
await ref.read(petListProvider.notifier).deletePet(editing.id);
// 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다
if (!await succeeded(ref.read(petListProvider.notifier).deletePet(editing.id))) return;
// 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다 (서버 모드는 서버가 이미 정리했다)
await ref.read(petCardsProvider.notifier).deleteForPet(editing.id);
await ref.read(scheduleProvider.notifier).dropTarget(editing.id);
await ref.read(diaryProvider.notifier).dropTarget(editing.id);
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
@@ -8,10 +9,15 @@ class PetListNotifier extends AsyncNotifier<List<Pet>> {
PetRepository get _repo => ref.read(petRepositoryProvider);
@override
Future<List<Pet>> build() => ref.watch(petRepositoryProvider).fetchPets();
Future<List<Pet>> build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return ref.watch(petRepositoryProvider).fetchPets();
}
Future<Pet> create(PetInput input) async {
final pet = await _repo.create(input);
// 고유색은 등록 순서대로 — 추억 보관함의 아이까지 센다
final pet = await _repo.create(input, tint: petTintFor(state.value?.length ?? 0));
if (ref.mounted) state = AsyncData([...?state.value, pet]);
return pet;
}
@@ -33,10 +39,17 @@ class PetListNotifier extends AsyncNotifier<List<Pet>> {
Future<void> archivePet(String id, PetArchive archive) async => _replace(await _repo.archive(id, archive));
/// 우리 아이 순서 바꾸기 — 화면에는 바로 반영하고 저장한다
/// 우리 아이 순서 바꾸기 — 화면에는 바로 반영하고 저장한다. 저장하지 못하면 원래 순서로 되돌린다
Future<void> reorder(List<String> ids) async {
state = AsyncData(reorderPets(state.value ?? const <Pet>[], ids));
await _repo.reorder(ids);
final before = state.value ?? const <Pet>[];
state = AsyncData(reorderPets(before, ids));
try {
final saved = await _repo.reorder(ids);
if (ref.mounted) state = AsyncData(saved);
} on Exception {
if (ref.mounted) state = AsyncData(before);
rethrow;
}
}
Future<void> restorePet(String id) async => _replace(await _repo.restore(id));
@@ -0,0 +1,106 @@
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/features/pet_card/data/pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 펫 명함 경로 (backend PetCardsController · RepresentativeCardController)
const String _kCardsPath = '/pet-cards';
String _representativePath(String petId) => '/pets/$petId/representative-card';
/// 서버의 꾸미기 값 → 앱의 CardStyle (배경 그림은 앱 그림 경로 또는 서버 파일)
CardStyle cardStyleFromApi(Map<String, Object?> json) {
final fallback = CardStyle.initial();
return CardStyle(
tint: colorFromHex(json['tint'] as String?) ?? fallback.tint,
tint2: colorFromHex(json['tint2'] as String?) ?? fallback.tint2,
nameBg: colorFromHex(json['nameBg'] as String?),
nameBgImage: (json['nameBgAsset'] as String?) ?? serverFileRefOrNull(json['nameBgFileId'] as String?),
textLight: json['textLight'] as bool? ?? false,
scrim: json['scrim'] as bool? ?? false,
scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light,
);
}
PetCard petCardFromApi(Map<String, Object?> json) => PetCard(
id: json['id']! as String,
petId: json['petId']! as String,
code: json['code'] as String?,
style: cardStyleFromApi(json['style']! as Map<String, Object?>),
);
/// 서버 저장소 — 배경 사진(기기 사진)은 저장할 때 올리고, 앱 그림은 경로만 보낸다
class ApiPetCardRepository implements PetCardRepository {
ApiPetCardRepository({required Dio dio, required SessionStore session, required FileUploader uploader})
: _dio = dio,
_session = session,
_uploader = uploader;
final Dio _dio;
final SessionStore _session;
final FileUploader _uploader;
Future<Map<String, Object?>> _styleToApi(CardStyle style) async {
final image = style.nameBgImage;
final asset = image != null && isBundledAsset(image) ? image : null;
return {
'tint': hexOf(style.tint),
'tint2': hexOf(style.tint2),
'nameBg': style.nameBg == null ? null : hexOf(style.nameBg!),
'nameBgAsset': asset,
'nameBgFileId': asset == null ? await _uploader.ensureUploaded(image) : null,
'textLight': style.textLight,
'scrim': style.scrim,
'scrimTone': style.scrimTone.name,
};
}
@override
Future<PetCardBook> fetch() async {
if (!_session.hasSession) return (cards: const <PetCard>[], representatives: const <String, String>{});
final reps = <String, String>{};
final cards = await fetchAllPages(_dio, _kCardsPath, (json) {
final card = petCardFromApi(json);
if (json['isRepresentative'] == true) reps[card.petId] = card.id;
return card;
});
return (cards: cards, representatives: reps);
}
@override
Future<PetCard> create(String petId, CardStyle style) async {
final response = await _dio.post<Map<String, dynamic>>(
_kCardsPath,
data: {'petId': petId, 'style': await _styleToApi(style)},
);
return petCardFromApi(response.data!);
}
@override
Future<PetCard> update(String cardId, CardStyle style) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kCardsPath/$cardId',
data: {'style': await _styleToApi(style)},
);
return petCardFromApi(response.data!);
}
/// 대표 명함이었으면 서버가 대표 지정도 함께 지운다
@override
Future<void> delete(String cardId) => _dio.delete<void>('$_kCardsPath/$cardId');
@override
Future<void> setRepresentative(String petId, String cardId) =>
_dio.put<void>(_representativePath(petId), data: {'cardId': cardId});
@override
Future<void> clearRepresentative(String petId) => _dio.delete<void>(_representativePath(petId));
/// 서버가 아이를 지우면서 명함도 함께 지웠다
@override
Future<void> dropPet(String petId) async {}
}
@@ -2,33 +2,43 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 펫 명함 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다.
/// 내 명함 + 아이별 대표 명함 (petId → cardId)
typedef PetCardBook = ({List<PetCard> cards, Map<String, String> representatives});
/// 펫 명함 저장소 계약 — 서버(ApiPetCardRepository) / 기기(LocalPetCardRepository)
abstract interface class PetCardRepository {
Future<List<PetCard>> fetchCards();
Future<PetCardBook> fetch();
Future<PetCard> create(String petId, CardStyle style);
Future<PetCard> update(String cardId, CardStyle style);
/// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다
Future<void> delete(String cardId);
/// 아이별 대표 명함 (petId → cardId)
Future<Map<String, String>> fetchRepresentatives();
Future<void> setRepresentative(String petId, String cardId);
Future<void> clearRepresentative(String petId);
/// 아이를 지웠을 때 그 아이의 명함을 지운다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다.
Future<void> dropPet(String petId);
}
const String _kCardsKey = 'petCards.v1';
const String _kRepKey = 'petCards.rep.v1';
const String _kBgImagesKey = 'petCards.bgImages.v1';
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트).
class LocalPetCardRepository implements PetCardRepository {
LocalPetCardRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
@@ -53,8 +63,10 @@ class LocalPetCardRepository implements PetCardRepository {
return (jsonDecode(raw) as Map<String, Object?>).map((k, v) => MapEntry(k, v! as String));
}
Future<void> _writeRep(Map<String, String> reps) => _prefs.setString(_kRepKey, jsonEncode(reps));
@override
Future<List<PetCard>> fetchCards() async => _read();
Future<PetCardBook> fetch() async => (cards: _read(), representatives: _readRep());
@override
Future<PetCard> create(String petId, CardStyle style) async {
@@ -74,21 +86,24 @@ class LocalPetCardRepository implements PetCardRepository {
@override
Future<void> delete(String cardId) async {
await _write(_read().where((c) => c.id != cardId).toList());
final reps = _readRep();
if (reps.containsValue(cardId)) await _writeRep(reps..removeWhere((_, id) => id == cardId));
}
@override
Future<Map<String, String>> fetchRepresentatives() async => _readRep();
Future<void> setRepresentative(String petId, String cardId) => _writeRep({..._readRep(), petId: cardId});
@override
Future<void> setRepresentative(String petId, String cardId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep(), petId: cardId}));
Future<void> clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId));
@override
Future<void> clearRepresentative(String petId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep()}..remove(petId)));
Future<void> dropPet(String petId) async {
await _write(_read().where((c) => c.petId != petId).toList());
await clearRepresentative(petId);
}
}
/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다
/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다 (기기에 둔다)
const int kMaxUserBackgrounds = 5;
class CardBackgroundRepository {
@@ -102,7 +117,14 @@ class CardBackgroundRepository {
}
final petCardRepositoryProvider = Provider<PetCardRepository>((ref) {
return LocalPetCardRepository(ref.watch(sharedPreferencesProvider));
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalPetCardRepository(ref.watch(sharedPreferencesProvider)),
DataMode.api => ApiPetCardRepository(
dio: ref.watch(dioProvider),
session: ref.watch(sessionStoreProvider),
uploader: ref.watch(fileUploaderProvider),
),
};
});
final cardBackgroundRepositoryProvider = Provider<CardBackgroundRepository>((ref) {
@@ -5,19 +5,23 @@ const int kMaxPetCards = 10;
/// 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값
class PetCard {
const PetCard({required this.id, required this.petId, required this.style});
const PetCard({required this.id, required this.petId, required this.style, this.code});
final String id;
final String petId;
final CardStyle style;
PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style);
/// 서버가 발급한 명함 코드 "0000-000" — 기기 저장 모드에는 없다 (화면은 displayCardCode 로 보여 준다)
final String? code;
Map<String, Object?> toJson() => {'id': id, 'petId': petId, 'style': style.toJson()};
PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style, code: code);
Map<String, Object?> toJson() => {'id': id, 'petId': petId, 'style': style.toJson(), 'code': ?code};
factory PetCard.fromJson(Map<String, Object?> json) => PetCard(
id: json['id']! as String,
petId: json['petId']! as String,
style: CardStyle.fromJson((json['style'] as Map<String, Object?>?) ?? const {}),
code: json['code'] as String?,
);
}
@@ -1,5 +1,7 @@
// 명함에 적히는 값 계산 — 순수 함수
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 만 나이(년) — 이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다
int petAgeYears(DateTime birth, DateTime today) {
var months = (today.year - birth.year) * 12 + (today.month - birth.month);
@@ -21,7 +23,7 @@ String shortBirth(DateTime d) {
}
/// 명함 코드 "0000-000" — 같은 시드면 항상 같은 코드 (시안 petCardCode).
/// 서버가 코드를 발급하게 되면 그 값으로 바꾼다.
/// 서버가 발급한 코드가 없을 때(기기 저장 모드)만 쓴다 — [displayCardCode].
String petCardCode(String seed) {
var h = 0;
for (final unit in seed.codeUnits) {
@@ -31,3 +33,6 @@ String petCardCode(String seed) {
final b = ((h ~/ 10000) % 1000).toString().padLeft(3, '0');
return '$a-$b';
}
/// 명함에 보여 줄 코드 — 서버가 발급한 코드, 없으면(기기 저장 모드) 명함 id 로 만든 코드
String displayCardCode(PetCard card) => card.code ?? petCardCode(card.id);
@@ -3,12 +3,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
@@ -46,10 +48,11 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
final _capture = CaptureController();
Future<void> _share(String petName) {
final card = ref.read(petCardsProvider).value?.cards.where((c) => c.id == widget.cardId).firstOrNull;
return showPetCardShareSheet(
context,
petName: petName,
codeSeed: widget.cardId,
code: card == null ? petCardCode(widget.cardId) : displayCardCode(card),
capture: _capture.capturePng,
who: TextSpan(children: [
TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
@@ -64,7 +67,7 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
toast.show('이미 대표 명함이에요', icon: RupIconName.star);
return;
}
await ref.read(petCardsProvider.notifier).setRepresentative(card);
if (!await succeeded(ref.read(petCardsProvider.notifier).setRepresentative(card))) return;
toast.show('대표 명함으로 설정했어요', icon: RupIconName.star);
}
@@ -86,7 +89,7 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
if (!ok || !mounted) return;
// 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다
context.pop();
await ref.read(petCardsProvider.notifier).delete(card.id);
if (!await succeeded(ref.read(petCardsProvider.notifier).delete(card.id))) return;
ref.read(toastProvider.notifier).show('펫 명함을 삭제했어요');
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -98,7 +99,7 @@ class _CardEditorState extends ConsumerState<_CardEditor> {
final file = await _pickImage();
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
await ref.read(petListProvider.notifier).updatePet(pet.id, pet.withPhoto(path));
await succeeded(ref.read(petListProvider.notifier).updatePet(pet.id, pet.withPhoto(path)));
}
Future<void> _addBackground() async {
@@ -151,6 +152,9 @@ class _CardEditorState extends ConsumerState<_CardEditor> {
// 만든 명함의 상세로 — 뒤로 가면 명함을 만들기 시작한 화면으로 돌아간다
if (mounted) context.pushReplacement(AppRoutes.petCardDetail(card.id));
}
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 꾸민 모양을 그대로 두고 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -11,6 +11,7 @@ import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/crown_mark.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
@@ -145,7 +146,7 @@ Future<void> sharePetCard(
return showPetCardShareSheet(
context,
petName: pet.name,
codeSeed: card.id,
code: displayCardCode(card),
capture: capture.capturePng,
who: TextSpan(children: [
TextSpan(text: pet.name, style: const TextStyle(color: AppColors.primary600)),
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/data/pet_card_repository.dart';
@@ -24,11 +25,10 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
@override
Future<PetCardState> build() async {
final repo = ref.watch(petCardRepositoryProvider);
return PetCardState(
cards: await repo.fetchCards(),
representatives: await repo.fetchRepresentatives(),
);
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
final book = await ref.watch(petCardRepositoryProvider).fetch();
return PetCardState(cards: book.cards, representatives: book.representatives);
}
PetCardState get _current => state.value ?? const PetCardState(cards: [], representatives: {});
@@ -48,25 +48,22 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
_emit(_current.copyWith(cards: [for (final c in _current.cards) c.id == cardId ? card : c]));
}
/// 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 (저장소가 정리한다)
Future<void> delete(String cardId) async {
final card = _current.cards.where((c) => c.id == cardId).firstOrNull;
await _repo.delete(cardId);
final reps = {..._current.representatives};
if (card != null && reps[card.petId] == cardId) {
reps.remove(card.petId);
await _repo.clearRepresentative(card.petId);
}
_emit(PetCardState(
cards: _current.cards.where((c) => c.id != cardId).toList(),
representatives: reps,
representatives: {..._current.representatives}..removeWhere((_, id) => id == cardId),
));
}
/// 아이를 삭제하면 그 아이의 명함도 함께 지운다
/// 아이를 삭제하면 그 아이의 명함도 함께 지운다 (서버 모드는 서버가 이미 지웠다)
Future<void> deleteForPet(String petId) async {
for (final card in _current.cards.where((c) => c.petId == petId).toList()) {
await delete(card.id);
}
await _repo.dropPet(petId);
_emit(PetCardState(
cards: _current.cards.where((c) => c.petId != petId).toList(),
representatives: {..._current.representatives}..remove(petId),
));
}
Future<void> setRepresentative(PetCard card) async {
@@ -8,7 +8,6 @@ import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/storage/album_saver.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/shared/widgets/blurred_modal.dart';
import 'package:tma_app/shared/widgets/capture_boundary.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
@@ -28,7 +27,7 @@ const double _kQrBadge = 54;
Future<void> showPetCardShareSheet(
BuildContext context, {
required String petName,
required String codeSeed,
required String code,
required InlineSpan who,
required Future<Uint8List?> Function() capture,
}) {
@@ -37,7 +36,7 @@ Future<void> showPetCardShareSheet(
title: '$petName 펫 명함 공유',
builder: (context) => _ShareBody(
petName: petName,
code: petCardCode(codeSeed),
code: code,
who: who,
capture: capture,
),
@@ -0,0 +1,157 @@
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/data/schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
/// 일정 경로 (backend ScheduleEventsController · RepeatRulesController)
const String _kEventsPath = '/schedule-events';
const String _kRulesPath = '/repeat-rules';
const String _kCompletionsPath = '/repeat-rule-completions';
/// 한 번에 만드는 일정 수 (backend SCHEDULE_LIMITS.eventsPerRequest) — 기간 설정은 나눠 보낸다
const int kEventsPerRequest = 500;
/// 앱의 EventDetails → 서버 본문
Map<String, Object?> eventDetailsToApi(EventDetails d) => {
'title': d.title,
'kind': d.kind.name,
'category': d.category.name,
'targetIds': d.targetIds,
'start': d.start?.hhmm,
'end': d.end?.hhmm,
'place': d.place,
'labelColor': d.labelColor == null ? null : hexOf(d.labelColor!),
};
/// 서버 응답의 일정 내용 → 앱의 EventDetails
EventDetails eventDetailsFromApi(Map<String, Object?> json) => EventDetails(
title: json['title']! as String,
kind: EventKind.byName(json['kind'] as String?),
category: EventCategory.byName(json['category'] as String?),
targetIds: (json['targetIds'] as List?)?.whereType<String>().toList() ?? const [],
start: HourMinute.tryParse(json['start'] as String?),
end: HourMinute.tryParse(json['end'] as String?),
place: json['place'] as String?,
labelColor: colorFromHex(json['labelColor'] as String?),
);
ScheduleEvent scheduleEventFromApi(Map<String, Object?> json) => ScheduleEvent(
id: json['id']! as String,
date: parseIsoDate(json['date']! as String),
details: eventDetailsFromApi(json),
);
RepeatSpec repeatSpecFromApi(Map<String, Object?> json) => RepeatSpec(
RepeatPattern.values.byName(json['mode']! as String),
unit: RepeatUnit.values.byName(json['unit'] as String? ?? RepeatUnit.week.name),
n: (json['n'] as num?)?.toInt() ?? 1,
);
RepeatRule repeatRuleFromApi(Map<String, Object?> json) => RepeatRule(
id: json['id']! as String,
from: parseIsoDate(json['from']! as String),
spec: repeatSpecFromApi(json['spec']! as Map<String, Object?>),
details: eventDetailsFromApi(json['details']! as Map<String, Object?>),
);
Map<String, Object?> _specToApi(RepeatSpec spec) => {'mode': spec.mode.name, 'unit': spec.unit.name, 'n': spec.n};
/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다
class ApiScheduleRepository implements ScheduleRepository {
ApiScheduleRepository({required Dio dio, required SessionStore session})
: _dio = dio,
_session = session;
final Dio _dio;
final SessionStore _session;
@override
Future<ScheduleBook> fetch() async {
if (!_session.hasSession) return ScheduleBook.empty;
final done = <String>{};
final events = await fetchAllPages(_dio, _kEventsPath, (json) {
final event = scheduleEventFromApi(json);
if (json['isDone'] == true) done.add(event.id);
return event;
});
final rules = await fetchAllPages(_dio, _kRulesPath, repeatRuleFromApi);
final completions = await fetchAllPages(
_dio,
_kCompletionsPath,
(json) => '${json['ruleId']! as String}@${json['date']! as String}',
);
return ScheduleBook(events: events, rules: rules, doneIds: {...done, ...completions});
}
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async {
final created = <ScheduleEvent>[];
for (var i = 0; i < drafts.length; i += kEventsPerRequest) {
final chunk = drafts.sublist(i, (i + kEventsPerRequest).clamp(0, drafts.length));
final response = await _dio.post<List<dynamic>>(
_kEventsPath,
data: {
'events': [
for (final d in chunk) {'date': toIsoDate(d.date), ...eventDetailsToApi(d.details)},
],
},
);
created.addAll(response.data!.map((json) => scheduleEventFromApi(json as Map<String, Object?>)));
}
return created;
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kEventsPath/${event.id}',
data: eventDetailsToApi(details),
);
return scheduleEventFromApi(response.data!);
}
@override
Future<void> deleteEvent(String eventId) => _dio.delete<void>('$_kEventsPath/$eventId');
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) async {
final response = await _dio.post<Map<String, dynamic>>(
_kRulesPath,
data: {'from': toIsoDate(from), 'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kRulesPath/${rule.id}',
data: {'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
}
@override
Future<void> deleteRule(String ruleId) => _dio.delete<void>('$_kRulesPath/$ruleId');
@override
Future<void> setDone(String occurrenceId, {required bool done}) {
final ruleId = ScheduleEvent.ruleIdOf(occurrenceId);
final path = ruleId == null
? '$_kEventsPath/$occurrenceId/completion'
: '$_kRulesPath/$ruleId/completions/${occurrenceId.split('@').last}';
return done ? _dio.put<void>(path) : _dio.delete<void>(path);
}
/// 서버가 아이를 지우거나 보관하면서 이미 일정에서 그 아이를 뗐다
@override
Future<void> dropTarget(String targetId) async {}
}
@@ -2,21 +2,45 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/care_preset.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
/// 일정 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다.
/// 일정 저장소 계약 — 서버(ApiScheduleRepository) / 기기(LocalScheduleRepository).
/// 바꾼 결과(id 가 붙은 일정·규칙)를 돌려주면 알림기가 [ScheduleBook] 에 반영한다.
abstract interface class ScheduleRepository {
Future<ScheduleBook> fetch();
Future<void> saveEvents(List<ScheduleEvent> events);
/// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts);
Future<void> saveRules(List<RepeatRule> rules);
/// 일정 내용 바꾸기 — 날짜·완료 표시는 그대로
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details);
Future<void> saveDone(Set<String> doneIds);
Future<void> deleteEvent(String eventId);
/// 끝나는 날 없는 반복 일정
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details);
/// 반복 주기·내용 바꾸기 — 시작한 날은 그대로
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details);
/// 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시
Future<void> deleteRule(String ruleId);
/// 완료 표시 — 날짜 일정은 일정 id, 반복 일정은 "규칙id@날짜"
Future<void> setDone(String occurrenceId, {required bool done});
/// 아이를 지우거나 보관했을 때 일정에서 그 아이를 뗀다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우거나 보관하면서 이미 정리했다.
Future<void> dropTarget(String targetId);
}
const String _kEventsKey = 'schedule.events.v1';
@@ -27,36 +51,94 @@ const String _kCaresKey = 'schedule.cares.v1';
List<Map<String, Object?>> _decodeList(String? raw) =>
raw == null ? const [] : (jsonDecode(raw) as List<Object?>).whereType<Map<String, Object?>>().toList();
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트).
class LocalScheduleRepository implements ScheduleRepository {
LocalScheduleRepository(this._prefs);
LocalScheduleRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
int _serial = 0;
@override
Future<ScheduleBook> fetch() async => ScheduleBook(
/// 같은 순간에 여러 건을 만들어도 겹치지 않는 id
String _newId(String prefix) => '$prefix${_clock().microsecondsSinceEpoch}_${_serial++}';
ScheduleBook _read() => ScheduleBook(
events: _decodeList(_prefs.getString(_kEventsKey)).map(ScheduleEvent.fromJson).toList(),
rules: _decodeList(_prefs.getString(_kRulesKey)).map(RepeatRule.fromJson).toList(),
doneIds: (_prefs.getStringList(_kDoneKey) ?? const []).toSet(),
);
@override
Future<void> saveEvents(List<ScheduleEvent> events) =>
_prefs.setString(_kEventsKey, jsonEncode([for (final e in events) e.toJson()]));
Future<void> _write(ScheduleBook book) async {
await _prefs.setString(_kEventsKey, jsonEncode([for (final e in book.events) e.toJson()]));
await _prefs.setString(_kRulesKey, jsonEncode([for (final r in book.rules) r.toJson()]));
await _prefs.setStringList(_kDoneKey, book.doneIds.toList());
}
@override
Future<void> saveRules(List<RepeatRule> rules) =>
_prefs.setString(_kRulesKey, jsonEncode([for (final r in rules) r.toJson()]));
Future<ScheduleBook> fetch() async => _read();
@override
Future<void> saveDone(Set<String> doneIds) => _prefs.setStringList(_kDoneKey, doneIds.toList());
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async {
final created = [
for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details),
];
await _write(_read().addEvents(created));
return created;
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) async {
final updated = event.copyWith(details: details);
await _write(_read().replaceEvent(updated));
return updated;
}
@override
Future<void> deleteEvent(String eventId) async {
final book = _read();
final event = book.events.where((e) => e.id == eventId).firstOrNull;
if (event != null) await _write(book.delete(event));
}
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) async {
final rule = RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details);
await _write(_read().addRule(rule));
return rule;
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async {
final updated = rule.copyWith(spec: spec, details: details);
await _write(_read().replaceRule(updated));
return updated;
}
@override
Future<void> deleteRule(String ruleId) async {
final book = _read();
final rule = book.rules.where((r) => r.id == ruleId).firstOrNull;
if (rule != null) await _write(book.delete(rule.instanceOn(rule.from)));
}
@override
Future<void> setDone(String occurrenceId, {required bool done}) async {
final book = _read();
if (book.isDone(occurrenceId) != done) await _write(book.toggleDone(occurrenceId));
}
@override
Future<void> dropTarget(String targetId) async => _write(_read().dropTarget(targetId));
}
final scheduleRepositoryProvider = Provider<ScheduleRepository>((ref) {
return LocalScheduleRepository(ref.watch(sharedPreferencesProvider));
return switch (ref.watch(dataModeProvider)) {
DataMode.local => LocalScheduleRepository(ref.watch(sharedPreferencesProvider)),
DataMode.api => ApiScheduleRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
};
});
/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다
/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다 (기기에 둔다)
class CarePresetRepository {
CarePresetRepository(this._prefs);
@@ -8,7 +8,7 @@ const int kUpcomingLookaheadDays = 90;
const int kUpcomingLimit = 4;
/// 일정 전체 — 날짜가 정해진 일정 + 끝없는 반복 규칙 + 완료 표시.
/// 바꾸는 메서드는 새 객체를 돌려준다 (저장은 호출한 쪽이 한다).
/// 바꾸는 메서드는 새 객체를 돌려준다 (저장은 저장소가 하고, 알림기가 결과를 여기에 반영한다).
class ScheduleBook {
const ScheduleBook({this.events = const [], this.rules = const [], this.doneIds = const {}});
@@ -78,41 +78,12 @@ class ScheduleBook {
ScheduleBook addRule(RepeatRule rule) => copyWith(rules: [...rules, rule]);
/// 일정 내용 수정 (기간 설정 없이) — 반복 여부 전환까지 처리한다 (시안 updateEvent)
/// - 반복 일정 + [spec] 있음 → 규칙의 주기·내용을 바꾼다
/// - 반복 일정 + [spec] 없음 → 반복을 끄고 보고 있던 날짜의 일정 하나로 남긴다
/// - 일반 일정 + [spec] 있음 → 그 날짜부터 반복 규칙으로 바꾼다
ScheduleBook update(
ScheduleEvent target,
EventDetails details, {
RepeatSpec? spec,
required String Function() newEventId,
required String Function() newRuleId,
}) {
final ruleId = target.ruleId;
if (ruleId != null) {
if (spec != null) {
return copyWith(rules: [
for (final r in rules) r.id == ruleId ? r.copyWith(spec: spec, details: details) : r,
]);
}
return copyWith(
rules: rules.where((r) => r.id != ruleId).toList(),
events: [...events, ScheduleEvent(id: newEventId(), date: target.date, details: details)],
doneIds: _withoutRule(ruleId),
);
}
if (spec != null) {
return copyWith(
events: events.where((e) => e.id != target.id).toList(),
rules: [...rules, RepeatRule(id: newRuleId(), spec: spec, from: target.date, details: details)],
doneIds: {...doneIds}..remove(target.id),
);
}
return copyWith(events: [
for (final e in events) e.id == target.id ? e.copyWith(details: details) : e,
]);
}
/// 날짜 일정 하나를 바꾼 것으로 대신한다
ScheduleBook replaceEvent(ScheduleEvent event) =>
copyWith(events: [for (final e in events) e.id == event.id ? event : e]);
/// 반복 규칙 하나를 바꾼 것으로 대신한다
ScheduleBook replaceRule(RepeatRule rule) => copyWith(rules: [for (final r in rules) r.id == rule.id ? rule : r]);
/// 일정(반복이면 규칙 전체)을 지우고 [list] 로 대신한다 — 기간 설정으로 바꾼 수정
ScheduleBook replace(ScheduleEvent target, List<ScheduleEvent> list) =>
@@ -77,6 +77,9 @@ class EventDetails {
);
}
/// 새로 만들 날짜 일정 한 건 — 날짜와 내용 (id 는 저장소가 붙인다)
typedef EventDraft = ({DateTime date, EventDetails details});
/// 달력에 놓인 일정 하나
class ScheduleEvent {
const ScheduleEvent({
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
@@ -322,6 +323,9 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
toast.show('일정을 추가했어요');
}
if (mounted) context.pop();
} catch (e) {
// 실패 알림은 인터셉터가 띄웠다 — 화면을 열어 둔 채 다시 시도할 수 있게 한다
if (!isReportedApiFailure(e)) rethrow;
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -338,7 +342,7 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
if (!ok || !mounted) return;
// 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다
context.pop();
await ref.read(scheduleProvider.notifier).delete(editing);
if (!await succeeded(ref.read(scheduleProvider.notifier).delete(editing))) return;
ref.read(toastProvider.notifier).show(editing.isRepeat ? '반복 일정을 삭제했어요' : '일정을 삭제했어요');
}
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/schedule/data/schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/care_preset.dart';
@@ -6,60 +7,99 @@ import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
/// 새로 만들 일정 한 건 — 날짜와 내용 (id 는 저장할 때 붙인다)
typedef EventDraft = ({DateTime date, EventDetails details});
/// 일정 전체 (날짜 일정 + 반복 규칙 + 완료 표시)
/// 저장은 저장소가 건별로 하고, 돌려받은 결과(id 가 붙은 일정·규칙)를 [ScheduleBook] 에 반영한다.
class ScheduleNotifier extends AsyncNotifier<ScheduleBook> {
ScheduleRepository get _repo => ref.read(scheduleRepositoryProvider);
int _serial = 0;
@override
Future<ScheduleBook> build() => ref.watch(scheduleRepositoryProvider).fetch();
Future<ScheduleBook> build() {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return ref.watch(scheduleRepositoryProvider).fetch();
}
ScheduleBook get _book => state.value ?? ScheduleBook.empty;
/// 같은 순간에 여러 건을 만들어도 겹치지 않는 id
String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}';
List<ScheduleEvent> _materialize(List<EventDraft> drafts) => [
for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details),
];
Future<void> _commit(ScheduleBook next) async {
await _repo.saveEvents(next.events);
await _repo.saveRules(next.rules);
await _repo.saveDone(next.doneIds);
void _set(ScheduleBook next) {
if (ref.mounted) state = AsyncData(next);
}
Future<void> addEvents(List<EventDraft> drafts) => _commit(_book.addEvents(_materialize(drafts)));
Future<void> addEvents(List<EventDraft> drafts) async {
final created = await _repo.createEvents([
for (final d in drafts) (date: dateOnly(d.date), details: d.details),
]);
_set(_book.addEvents(created));
}
/// 끝나는 날 없는 반복 일정
Future<void> addRule(DateTime from, RepeatSpec spec, EventDetails details) => _commit(
_book.addRule(RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details)),
);
Future<void> addRule(DateTime from, RepeatSpec spec, EventDetails details) async {
_set(_book.addRule(await _repo.createRule(dateOnly(from), spec, details)));
}
Future<void> edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) => _commit(
_book.update(
target,
details,
spec: spec,
newEventId: () => _newId('e'),
newRuleId: () => _newId('r'),
),
);
/// 일정 내용 수정 (기간 설정 없이) — 반복 여부 전환까지 처리한다 (시안 updateEvent)
/// - 반복 일정 + [spec] 있음 → 규칙의 주기·내용을 바꾼다
/// - 반복 일정 + [spec] 없음 → 반복을 끄고 보고 있던 날짜의 일정 하나로 남긴다 (규칙의 완료 표시는 지운다)
/// - 일반 일정 + [spec] 있음 → 그 날짜부터 반복 규칙으로 바꾼다
/// 바꾸는 일은 새 것을 먼저 만들고 옛것을 지운다 — 중간에 실패해도 일정이 사라지지 않는다.
Future<void> edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) async {
final rule = _book.ruleOf(target);
if (rule != null) {
if (spec != null) {
_set(_book.replaceRule(await _repo.updateRule(rule, spec, details)));
return;
}
final created = await _repo.createEvents([(date: target.date, details: details)]);
await _repo.deleteRule(rule.id);
_set(_book.delete(target).addEvents(created));
return;
}
if (spec != null) {
final created = await _repo.createRule(target.date, spec, details);
await _repo.deleteEvent(target.id);
_set(_book.delete(target).addRule(created));
return;
}
_set(_book.replaceEvent(await _repo.updateEvent(target, details)));
}
/// 기간 설정으로 바꾼 수정 — 원래 일정(반복이면 규칙 전체)을 새 목록으로 대신한다
Future<void> replace(ScheduleEvent target, List<EventDraft> drafts) =>
_commit(_book.replace(target, _materialize(drafts)));
Future<void> replace(ScheduleEvent target, List<EventDraft> drafts) async {
final created = await _repo.createEvents([
for (final d in drafts) (date: dateOnly(d.date), details: d.details),
]);
await _deleteTarget(target);
_set(_book.replace(target, created));
}
Future<void> delete(ScheduleEvent target) => _commit(_book.delete(target));
/// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다
Future<void> delete(ScheduleEvent target) async {
await _deleteTarget(target);
_set(_book.delete(target));
}
Future<void> toggleDone(String id) => _commit(_book.toggleDone(id));
Future<void> _deleteTarget(ScheduleEvent target) {
final ruleId = target.ruleId;
return ruleId == null ? _repo.deleteEvent(target.id) : _repo.deleteRule(ruleId);
}
/// 아이를 지우면 일정에서 그 아이만 뗀다
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
/// 완료 체크 — 화면에는 바로 반영하고 저장한다. 저장에 실패하면 되돌린다 (알림은 인터셉터가 띄운다)
Future<void> toggleDone(String id) async {
final done = !_book.isDone(id);
_set(_book.toggleDone(id));
try {
await _repo.setDone(id, done: done);
} on Exception {
if (_book.isDone(id) == done) _set(_book.toggleDone(id));
rethrow;
}
}
/// 아이를 지우면 일정에서 그 아이만 뗀다 (서버 모드는 서버가 이미 정리했다)
Future<void> dropTarget(String targetId) async {
await _repo.dropTarget(targetId);
_set(_book.dropTarget(targetId));
}
}
final scheduleProvider = AsyncNotifierProvider<ScheduleNotifier, ScheduleBook>(ScheduleNotifier.new);
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
@@ -94,7 +95,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
event: e,
done: book.isDone(e.id),
onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))),
onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)),
onToggleDone: () => unawaited(succeeded(ref.read(scheduleProvider.notifier).toggleDone(e.id))),
),
],
],
@@ -1,27 +0,0 @@
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
class UserModel {
const UserModel({
required this.id,
required this.email,
required this.name,
});
final int id;
final String email;
final String name;
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as int,
email: json['email'] as String,
name: json['name'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
};
}
@@ -1,44 +0,0 @@
import 'package:dio/dio.dart';
import '../models/user_model.dart';
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
///
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
class UserRepository {
const UserRepository(this._dio);
final Dio _dio;
Future<UserModel> getUser(int id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return UserModel.fromJson(response.data!);
}
/// 커서 기반 목록 조회 — 목록 API 는 항상 페이지네이션을 적용한다.
Future<({List<UserModel> items, String? nextCursor, bool hasNext})> getUsers({
int limit = 20,
String? cursor,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/users',
queryParameters: {
'limit': limit,
'cursor': ?cursor,
},
);
final data = response.data!;
final items = (data['items'] as List<dynamic>)
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
.toList();
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
return (
items: items,
nextCursor: pageInfo['nextCursor'] as String?,
hasNext: pageInfo['hasNext'] as bool? ?? false,
);
}
}
@@ -1,15 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/api/dio_provider.dart';
import '../../data/models/user_model.dart';
import '../../data/repositories/user_repository.dart';
// 리포지토리 Provider — dioProvider 를 주입받아 생성
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(dioProvider));
});
// 특정 사용자 조회 Provider (family)
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
return ref.watch(userRepositoryProvider).getUser(id);
});
+171
View File
@@ -0,0 +1,171 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'support/fake_api.dart';
/// 1×1 PNG — 올리기 테스트용 기기 사진
final Uint8List kPngBytes = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48, 0x44, 0x52, //
]);
Map<String, Object?> _me({String? nickname = '콩이 보호자', String? photoFileId}) => {
'id': 'user-1',
'nickname': nickname,
'photoFileId': photoFileId,
'provider': 'kakao',
'createdAt': '2026-10-11T00:00:00.000Z',
};
void main() {
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
group('ApiAuthRepository', () {
ApiAuthRepository repo(FakeApi api) => ApiAuthRepository(
dio: api.dio,
session: api.session,
prefs: prefs,
deviceKeys: const FixedDeviceKeyStore('device-key-abcdefghijklmnop'),
);
test('개발용 로그인으로 가입하고 토큰·계정 종류·이름을 저장한다', () async {
final api = FakeApi(tokens: null);
api.on('POST', '/auth/dev-sessions', (r) {
expect(r.json, {'provider': 'naver', 'deviceKey': 'device-key-abcdefghijklmnop'});
return ok({
'accessToken': 'a1',
'refreshToken': 'r1',
'accessTokenExpiresIn': 3600,
'refreshTokenExpiresAt': '2026-10-25T00:00:00.000Z',
'user': _me(nickname: '두부맘')..['provider'] = 'naver',
'isNewUser': true,
}, status: 201);
});
final auth = repo(api);
expect(auth.hasSession, isFalse);
await auth.signIn(SocialProvider.naver);
expect(auth.hasSession, isTrue);
expect(api.session.tokens?.accessToken, 'a1');
expect(auth.provider, SocialProvider.naver);
expect(auth.nickname, '두부맘');
});
test('로그아웃은 리프레시 토큰을 폐기하고 세션을 지운다', () async {
final api = FakeApi();
api.on('POST', '/auth/token-revocation', (r) {
expect(r.json, {'refreshToken': 'refresh'});
return noContent;
});
final auth = repo(api);
await auth.signOut();
expect(api.session.hasSession, isFalse);
expect(api.sent('POST', '/auth/token-revocation'), hasLength(1));
});
test('폐기 요청이 실패해도 이 기기에서는 로그아웃하고 알림을 띄우지 않는다', () async {
final api = FakeApi();
api.on('POST', '/auth/token-revocation', (r) => failure('SYS_001', 500));
await repo(api).signOut();
expect(api.session.hasSession, isFalse);
expect(api.reported, isEmpty);
});
test('탈퇴는 서버에서 회원을 지운 뒤 세션을 지운다 — 실패하면 세션을 그대로 둔다', () async {
final api = FakeApi();
api.on('DELETE', '/users/me', (r) => failure('SYS_001', 500));
final auth = repo(api);
await expectLater(auth.deleteAccount(), throwsA(isA<DioException>()));
expect(api.session.hasSession, isTrue);
api.on('DELETE', '/users/me', (r) => noContent);
await auth.deleteAccount();
expect(api.session.hasSession, isFalse);
});
});
group('ApiProfileRepository', () {
late Directory temp;
setUp(() => temp = Directory.systemTemp.createTempSync('rup-profile'));
tearDown(() => temp.deleteSync(recursive: true));
ApiProfileRepository repo(FakeApi api) => ApiProfileRepository(
dio: api.dio,
session: api.session,
uploader: FileUploader(api.dio, api.reported.add),
prefs: prefs,
);
test('서버의 내 정보를 받아 기기에 두고, 사진은 서버 파일로 가리킨다', () async {
final api = FakeApi();
api.on('GET', '/users/me', (r) => ok(_me(photoFileId: 'file-1')));
final profiles = repo(api);
final profile = await profiles.fetch();
expect(profile, const UserProfile(nickname: '콩이 보호자', photoPath: 'server-file:file-1'));
// 다음 실행에서는 서버를 기다리지 않고 먼저 보여 준다
expect(profiles.load(), profile);
});
test('로그인 전에는 서버를 부르지 않는다', () async {
final api = FakeApi(tokens: null);
expect(await repo(api).fetch(), const UserProfile());
expect(api.requests, isEmpty);
});
test('기기 사진은 올린 뒤 그 파일 id 로 프로필을 바꾼다', () async {
final photo = File('${temp.path}/me.jpg')..writeAsBytesSync(kPngBytes);
final api = FakeApi();
api.on('POST', '/files', (r) {
final form = r.options.data as FormData;
final file = form.files.single;
expect(file.key, 'file');
// 원본 이름 대신 내용에 맞춘 이름·형식으로 보낸다 (확장자가 .jpg 여도 내용은 PNG)
expect(file.value.filename, 'photo.png');
expect(file.value.contentType.toString(), 'image/png');
return ok({'id': 'file-9', 'originalName': 'photo.png', 'mimeType': 'image/png', 'size': 16}, status: 201);
});
api.on('PATCH', '/users/me', (r) {
expect(r.json, {'nickname': '집사', 'photoFileId': 'file-9'});
return ok(_me(nickname: '집사', photoFileId: 'file-9'));
});
final saved = await repo(api).save(UserProfile(nickname: '집사', photoPath: photo.path));
expect(saved.photoPath, 'server-file:file-9');
});
test('로그아웃하면 기기에 둔 프로필을 지운다', () async {
final api = FakeApi();
api.on('GET', '/users/me', (r) => ok(_me()));
final profiles = repo(api);
await profiles.fetch();
await profiles.forget();
expect(profiles.load(), const UserProfile());
});
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'support/fake_api.dart';
Map<String, Object?> _diaryJson({String id = 'd1', String? coverFileId}) => {
'id': id,
'name': '콩이의 하루',
'targetIds': ['p1'],
'ribbon': '#FF7500',
'tint': '#FF8A5B',
'coverFileId': coverFileId,
'mutedNotices': ['reminder'],
'createdAt': '2026-10-11T00:00:00.000Z',
};
Map<String, Object?> _postJson({String id = 'post-1', String authorId = 'me-uuid', String date = '2026-10-11'}) => {
'id': id,
'diaryId': 'd1',
'authorId': authorId,
'date': date,
'createdAt': '2026-10-11T03:00:00.000Z',
'text': '산책했어요',
'mood': 'happy',
'photoFileId': 'f1',
};
void main() {
late FakeApi api;
late ApiDiaryRepository repo;
String? myId = 'me-uuid';
setUp(() {
api = FakeApi();
myId = 'me-uuid';
repo = ApiDiaryRepository(
dio: api.dio,
session: api.session,
uploader: FileUploader(api.dio, api.reported.add),
myUserId: () => myId,
);
});
test('다이어리·기록·댓글을 불러와 하나의 장부로 합친다 — 내가 쓴 기록은 "나"로 담는다', () async {
api.on('GET', '/diaries', (r) => ok(page([_diaryJson(coverFileId: 'cover-1')])));
api.on('GET', '/diary-posts', (r) => ok(page([_postJson(), _postJson(id: 'post-2', authorId: 'friend-uuid')])));
api.on('GET', '/diary-comments', (r) => ok(page([
{'id': 'c1', 'postId': 'post-1', 'authorId': 'me-uuid', 'text': '귀여워', 'createdAt': '2026-10-11T04:00:00.000Z'},
])));
final book = await repo.fetch();
final diary = book.diaries.single;
expect(diary.coverPath, 'server-file:cover-1');
expect(diary.mutedNotices, {DiaryNotice.reminder});
expect(diary.ribbon, const Color(0xFFFF7500));
final mine = book.posts.firstWhere((p) => p.id == 'post-1');
expect(mine.authorId, kMeAuthorId);
expect(mine.mood, Mood.happy);
expect(mine.photoPath, 'server-file:f1');
expect(book.posts.firstWhere((p) => p.id == 'post-2').authorId, 'friend-uuid');
expect(book.comments.single.authorId, kMeAuthorId);
// 하루 한 번 쓰기 — 내가 오늘 쓴 기록을 알아본다
expect(book.wroteOn('d1', DateTime(2026, 10, 11)), isTrue);
});
test('책장이 가득 차면(DIARY_001) null 로 받고 전역 알림은 띄우지 않는다', () async {
api.on('POST', '/diaries', (r) => failure('DIARY_001', 409));
final created = await repo.createDiary(
name: '새 다이어리',
targetIds: const [],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
);
expect(created, isNull);
expect(api.reported, isEmpty);
});
test('기록 남기기는 기기의 오늘 날짜와 기분·사진을 보낸다', () async {
api.on('POST', '/diaries/:id/posts', (r) {
expect(r.params['id'], 'd1');
expect(r.json, {'date': '2026-10-11', 'text': '산책했어요', 'mood': 'happy', 'photoFileId': 'f1'});
return ok(_postJson(), status: 201);
});
final post = await repo.addPost(
diaryId: 'd1',
date: DateTime(2026, 10, 11),
text: '산책했어요',
mood: Mood.happy,
photoPath: 'server-file:f1',
);
expect(post.authorId, kMeAuthorId);
expect(api.sent('POST', '/files'), isEmpty);
});
test('오늘 이미 기록했으면(DIARY_002) 서버 문구 대신 사전 문구로 알린다', () async {
api.on('POST', '/diaries/:id/posts', (r) => failure('DIARY_002', 409));
await expectLater(
repo.addPost(diaryId: 'd1', date: DateTime(2026, 10, 11), text: '두 번째'),
throwsA(anything),
);
expect(api.reported.single.code, 'DIARY_002');
expect(api.reported.single.message, isNot('서버 문구'));
});
test('다이어리 수정은 바탕색을 보내지 않는다 (만들 때 정한 값을 유지한다)', () async {
api.on('PATCH', '/diaries/:id', (r) {
expect(r.json!.containsKey('tint'), isFalse);
expect(r.json!['mutedNotices'], ['newComment']);
return ok(_diaryJson());
});
final diary = Diary(
id: 'd1',
name: '콩이의 하루',
targetIds: const ['p1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
mutedNotices: const {DiaryNotice.newComment},
);
await repo.updateDiary(diary);
expect(api.sent('PATCH', '/diaries/d1'), hasLength(1));
});
test('아이를 지울 때 다이어리 정리는 서버가 이미 했다', () async {
await repo.dropTarget('p1');
expect(api.requests, isEmpty);
});
}
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/app.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart';
import 'support/app_harness.dart' show settle;
import 'support/fake_api.dart';
/// 서버에 이미 등록된 아이
Map<String, Object?> _kongJson() => {
'id': 'pet-kong',
'tint': '#FF9B6A',
'name': '콩이',
'species': '강아지',
'breed': '말티즈',
'gender': 'male',
'neutered': true,
'birthDate': '2021-09-14',
'weight': 3.2,
'weightUnit': 'kg',
'photoFileId': null,
'microchip': null,
'bloodType': null,
'vet': null,
'memo': null,
'allergies': <String>[],
'conditions': <String>[],
'watchConditions': <String>[],
'archive': null,
};
/// 서버 하나를 흉내 낸다 — 로그인·내 정보·목록들
void _serve(FakeApi api, {required bool sessionAlive}) {
api.on('POST', '/auth/dev-sessions', (r) => ok({
'accessToken': 'access-1',
'refreshToken': 'refresh-1',
'accessTokenExpiresIn': 3600,
'refreshTokenExpiresAt': '2026-10-25T00:00:00.000Z',
'user': {
'id': 'user-1',
'nickname': '콩이 보호자',
'photoFileId': null,
'provider': 'kakao',
'createdAt': '2026-10-11T00:00:00.000Z',
},
'isNewUser': true,
}, status: 201));
api.on('POST', '/auth/tokens', (r) => failure('AUTH_001', 401));
FakeReply guarded(Object? data) => sessionAlive ? ok(data) : failure('AUTH_001', 401);
api.on('GET', '/users/me', (r) => guarded({
'id': 'user-1',
'nickname': '콩이 보호자',
'photoFileId': null,
'provider': 'kakao',
'createdAt': '2026-10-11T00:00:00.000Z',
}));
api.on('GET', '/pets', (r) => guarded(page([_kongJson()])));
for (final path in [
'/schedule-events',
'/repeat-rules',
'/repeat-rule-completions',
'/diaries',
'/diary-posts',
'/diary-comments',
'/pet-cards',
]) {
api.on('GET', path, (r) => guarded(page(const [])));
}
}
Future<void> _pumpApiApp(WidgetTester tester, FakeApi api, Map<String, Object> saved) async {
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues(saved);
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
dataModeProvider.overrideWithValue(DataMode.api),
sessionStoreProvider.overrideWithValue(api.session),
dioProvider.overrideWithValue(api.dio),
deviceKeyStoreProvider.overrideWithValue(const FixedDeviceKeyStore('test-device-key-000001')),
],
child: const RupApp(),
),
);
// 스플래시 이미지 디코딩 → 2.6초 뒤 다음 화면
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
await tester.pump(const Duration(seconds: 4));
await settle(tester);
}
/// 가짜 서버 응답(비동기)이 화면에 반영되고, 그 화면이 부른 요청까지 모두 끝날 때까지
Future<void> _network(WidgetTester tester, FakeApi api) async {
var seen = -1;
for (var i = 0; i < 10 && seen != api.requests.length; i++) {
seen = api.requests.length;
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await settle(tester);
}
}
void main() {
testWidgets('서버 모드: 로그인하면 토큰을 받고 온보딩을 거쳐 홈에서 서버의 아이를 보여 준다', (tester) async {
final api = FakeApi(tokens: null);
_serve(api, sessionAlive: true);
await _pumpApiApp(tester, api, {});
await tester.tap(find.text('시작하기'));
await settle(tester);
expect(find.byType(LoginPage), findsOneWidget);
await tester.tap(find.text('카카오로 3초만에 시작하기'));
await _network(tester, api);
expect(api.session.tokens?.accessToken, 'access-1');
expect(find.byType(OnboardingPage), findsOneWidget);
for (var i = 0; i < 3; i++) {
await tester.tap(find.byType(OnboardingPage));
await tester.pump(const Duration(milliseconds: 100));
}
await tester.tap(find.text('시작하기'));
await _network(tester, api);
expect(find.byType(RupTabBar), findsOneWidget);
expect(find.text('콩이'), findsWidgets);
expect(api.sent('GET', '/pets'), isNotEmpty);
expect(api.reported, isEmpty);
});
testWidgets('서버가 세션을 끊으면(갱신 거절) 로그인 화면으로 돌아가고 세션 만료로 알린다', (tester) async {
final api = FakeApi(tokens: const AuthTokens(accessToken: 'stale', refreshToken: 'revoked'));
_serve(api, sessionAlive: false);
await _pumpApiApp(tester, api, {'entry.onboardingSeen': true});
await _network(tester, api);
expect(api.session.hasSession, isFalse);
expect(api.session.expired, isTrue);
expect(find.byType(LoginPage), findsOneWidget);
expect(api.reported.map((e) => e.code).toSet(), {'AUTH_002'});
});
testWidgets('로그인 전(세션 없음)에는 서버에 기록을 묻지 않는다', (tester) async {
final api = FakeApi(tokens: null);
_serve(api, sessionAlive: true);
await _pumpApiApp(tester, api, {});
expect(api.requests, isEmpty);
});
}
@@ -0,0 +1,113 @@
import 'dart:io';
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'api_account_test.dart' show kPngBytes;
import 'support/fake_api.dart';
Map<String, Object?> _cardJson({
String id = 'c1',
String petId = 'p1',
bool isRepresentative = false,
String? nameBgAsset,
String? nameBgFileId,
}) =>
{
'id': id,
'petId': petId,
'code': '0428-117',
'isRepresentative': isRepresentative,
'style': {
'tint': '#5AA0E0',
'tint2': '#9CCBF0',
'nameBg': null,
'nameBgAsset': nameBgAsset,
'nameBgFileId': nameBgFileId,
'textLight': true,
'scrim': true,
'scrimTone': 'dark',
},
};
void main() {
late FakeApi api;
late ApiPetCardRepository repo;
setUp(() {
api = FakeApi();
repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
});
test('명함 목록에서 대표 명함을 아이별로 모으고, 서버가 발급한 코드를 담는다', () async {
api.on('GET', '/pet-cards', (r) => ok(page([
_cardJson(nameBgAsset: 'assets/svg/card_bg/bg_1.svg'),
_cardJson(id: 'c2', isRepresentative: true, nameBgFileId: 'f1'),
_cardJson(id: 'c3', petId: 'p2'),
])));
final book = await repo.fetch();
expect(book.cards.map((c) => c.id), ['c1', 'c2', 'c3']);
expect(book.representatives, {'p1': 'c2'});
expect(book.cards.first.code, '0428-117');
expect(book.cards.first.style.nameBgImage, 'assets/svg/card_bg/bg_1.svg');
expect(book.cards[1].style.nameBgImage, 'server-file:f1');
expect(book.cards.first.style.scrimTone, ScrimTone.dark);
});
test('앱 그림 배경은 경로만 보내고 올리지 않는다', () async {
api.on('PUT', '/pet-cards/:id', (r) {
final style = r.json!['style'] as Map<String, dynamic>;
expect(style['nameBgAsset'], 'assets/svg/card_bg/bg_2.svg');
expect(style['nameBgFileId'], isNull);
return ok(_cardJson(nameBgAsset: 'assets/svg/card_bg/bg_2.svg'));
});
await repo.update('c1', CardStyle.initial().withBackgroundImage('assets/svg/card_bg/bg_2.svg'));
expect(api.sent('POST', '/files'), isEmpty);
});
test('기기 사진 배경은 올린 뒤 파일 id 로 보낸다', () async {
final dir = Directory.systemTemp.createTempSync('rup-card');
addTearDown(() => dir.deleteSync(recursive: true));
final photo = File('${dir.path}/bg.png')..writeAsBytesSync(kPngBytes);
api.on('POST', '/files', (r) => ok({'id': 'f7'}, status: 201));
api.on('POST', '/pet-cards', (r) {
expect(r.json!['petId'], 'p1');
final style = r.json!['style'] as Map<String, dynamic>;
expect(style['nameBgFileId'], 'f7');
expect(style['nameBgAsset'], isNull);
expect(style['tint'], '#FF9B6A');
return ok(_cardJson(nameBgFileId: 'f7'), status: 201);
});
final card = await repo.create('p1', CardStyle.forPet(const Color(0xFFFF9B6A)).withBackgroundImage(photo.path));
expect(card.style.nameBgImage, 'server-file:f7');
});
test('대표 명함은 아이의 하위 경로로 정하고 푼다', () async {
api.on('PUT', '/pets/:petId/representative-card', (r) {
expect(r.json, {'cardId': 'c1'});
return noContent;
});
api.on('DELETE', '/pets/:petId/representative-card', (r) => noContent);
await repo.setRepresentative('p1', 'c1');
await repo.clearRepresentative('p1');
expect(api.sent('PUT', '/pets/p1/representative-card'), hasLength(1));
expect(api.sent('DELETE', '/pets/p1/representative-card'), hasLength(1));
});
test('아이를 지울 때 명함 정리는 서버가 이미 했다', () async {
await repo.dropPet('p1');
expect(api.requests, isEmpty);
});
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:io';
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'api_account_test.dart' show kPngBytes;
import 'support/fake_api.dart';
Map<String, Object?> _petJson({String id = 'p1', String name = '콩이', String? photoFileId, Object? archive}) => {
'id': id,
'tint': '#6FB8E8',
'name': name,
'species': '강아지',
'breed': '말티즈',
'gender': 'female',
'neutered': true,
'birthDate': '2021-09-14',
'weight': 3.2,
'weightUnit': 'kg',
'photoFileId': photoFileId,
'microchip': null,
'bloodType': 'DEA 1.1',
'vet': null,
'memo': '간식 좋아함',
'allergies': ['닭고기'],
'conditions': <String>[],
'watchConditions': ['슬개골'],
'archive': archive,
};
PetInput _input({String? photoPath}) => PetInput(
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: PetGender.female,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: 3.2,
weightUnit: WeightUnit.kg,
photoPath: photoPath,
allergies: const ['닭고기'],
);
void main() {
late FakeApi api;
late ApiPetRepository repo;
setUp(() {
api = FakeApi();
repo = ApiPetRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
});
test('서버 응답을 앱의 Pet 으로 옮긴다 — 사진은 서버 파일, 보관 기록은 PetArchive', () {
final pet = petFromApi(
_petJson(
photoFileId: 'f1',
archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': '고마워'},
),
);
expect(pet.id, 'p1');
expect(pet.tint, const Color(0xFF6FB8E8));
expect(pet.gender, PetGender.female);
expect(pet.birthDate, DateTime(2021, 9, 14));
expect(pet.photoPath, 'server-file:f1');
expect(pet.allergies, ['닭고기']);
expect(pet.watchConditions, ['슬개골']);
expect(pet.archive?.to, DateTime(2026, 10, 1));
expect(pet.archive?.memo, '고마워');
});
test('목록은 마지막 페이지까지 이어 읽는다', () async {
api.on(
'GET',
'/pets',
(r) =>
r.query['cursor'] == null ? ok(page([_petJson(id: 'a')], nextCursor: 'c1')) : ok(page([_petJson(id: 'b')])),
);
final pets = await repo.fetchPets();
expect(pets.map((p) => p.id), ['a', 'b']);
expect(api.sent('GET', '/pets').map((r) => r.query['limit']), [100, 100]);
});
test('로그인 전에는 목록을 부르지 않는다', () async {
final signedOut = FakeApi(tokens: null);
final r = ApiPetRepository(
dio: signedOut.dio,
session: signedOut.session,
uploader: FileUploader(signedOut.dio, (_) {}),
);
expect(await r.fetchPets(), isEmpty);
expect(signedOut.requests, isEmpty);
});
test('등록할 때 기기 사진을 먼저 올리고 고유색과 함께 보낸다', () async {
final dir = Directory.systemTemp.createTempSync('rup-pet');
addTearDown(() => dir.deleteSync(recursive: true));
final photo = File('${dir.path}/kong.png')..writeAsBytesSync(kPngBytes);
api.on('POST', '/files', (r) => ok({'id': 'f9'}, status: 201));
api.on('POST', '/pets', (r) {
expect(r.json?['tint'], '#FF9B6A');
expect(r.json?['photoFileId'], 'f9');
expect(r.json?['birthDate'], '2021-09-14');
expect(r.json?['gender'], 'female');
return ok(_petJson(photoFileId: 'f9'), status: 201);
});
final pet = await repo.create(_input(photoPath: photo.path), tint: const Color(0xFFFF9B6A));
expect(pet.photoPath, 'server-file:f9');
});
test('수정할 때 이미 올린 사진은 다시 올리지 않는다', () async {
api.on('PATCH', '/pets/:id', (r) {
expect(r.params['id'], 'p1');
expect(r.json?['photoFileId'], 'f1');
expect(r.json!.containsKey('tint'), isFalse);
return ok(_petJson(photoFileId: 'f1'));
});
await repo.update('p1', _input(photoPath: 'server-file:f1'));
expect(api.sent('POST', '/files'), isEmpty);
});
test('보관하기는 날짜만 보내고(보관한 때는 서버가 정한다) 다시 불러오기는 보관 기록을 지운다', () async {
api.on('PUT', '/pets/:id/archive', (r) {
expect(r.json, {'from': '2021-09-14', 'to': '2026-10-01', 'memo': null});
return ok(
_petJson(
archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': null},
),
);
});
api.on('DELETE', '/pets/:id/archive', (r) => ok(_petJson()));
final archived = await repo.archive(
'p1',
PetArchive(from: DateTime(2021, 9, 14), to: DateTime(2026, 10, 1), archivedAt: DateTime(2026, 10, 11)),
);
expect(archived.isArchived, isTrue);
expect((await repo.restore('p1')).isArchived, isFalse);
});
test('순서를 저장한 뒤 서버 순서로 다시 읽는다', () async {
api.on('PUT', '/pets/order', (r) {
expect(r.json, {
'ids': ['b', 'a'],
});
return noContent;
});
api.on('GET', '/pets', (r) => ok(page([_petJson(id: 'b'), _petJson(id: 'a')])));
final pets = await repo.reorder(['b', 'a']);
expect(pets.map((p) => p.id), ['b', 'a']);
});
}
@@ -0,0 +1,150 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'support/fake_api.dart';
const EventDetails _details = EventDetails(
title: '병원',
kind: EventKind.reservation,
category: EventCategory.hospital,
targetIds: ['p1', 'self'],
start: HourMinute(10, 30),
end: HourMinute(11, 0),
place: '동물병원',
labelColor: Color(0xFFFF6B6B),
);
Map<String, Object?> _detailsJson() => {
'title': '병원',
'kind': 'reservation',
'category': 'hospital',
'targetIds': ['p1', 'self'],
'start': '10:30',
'end': '11:00',
'place': '동물병원',
'labelColor': '#FF6B6B',
};
Map<String, Object?> _eventJson(String id, String date, {bool isDone = false}) =>
{'id': id, 'date': date, 'isDone': isDone, ..._detailsJson()};
void main() {
late FakeApi api;
late ApiScheduleRepository repo;
setUp(() {
api = FakeApi();
repo = ApiScheduleRepository(dio: api.dio, session: api.session);
});
test('일정 내용은 서버 형식(HH:mm · #RRGGBB)으로 오가도 같다', () {
expect(eventDetailsToApi(_details), _detailsJson());
final back = eventDetailsFromApi(_detailsJson());
expect(back.start, const HourMinute(10, 30));
expect(back.labelColor, const Color(0xFFFF6B6B));
expect(back.targetIds, ['p1', 'self']);
});
test('불러오기는 날짜 일정·반복 규칙·완료 표시를 하나의 일정 장부로 합친다', () async {
api.on('GET', '/schedule-events', (r) => ok(page([
_eventJson('e1', '2026-10-12', isDone: true),
_eventJson('e2', '2026-10-13'),
])));
api.on('GET', '/repeat-rules', (r) => ok(page([
{
'id': 'r1',
'from': '2026-10-11',
'spec': {'mode': 'every', 'unit': 'week', 'n': 2},
'details': _detailsJson(),
},
])));
api.on('GET', '/repeat-rule-completions', (r) => ok(page([
{'ruleId': 'r1', 'date': '2026-10-11'},
])));
final book = await repo.fetch();
expect(book.events.map((e) => e.id), ['e1', 'e2']);
expect(book.rules.single.spec, const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2));
expect(book.doneIds, {'e1', 'r1@2026-10-11'});
expect(book.eventsOn(DateTime(2026, 10, 25)).single.ruleId, 'r1');
});
test('기간 설정처럼 많은 일정은 500건씩 나눠 보낸다', () async {
final sizes = <int>[];
api.on('POST', '/schedule-events', (r) {
final events = r.json!['events'] as List<dynamic>;
sizes.add(events.length);
return ok([
for (final (i, e) in events.indexed) _eventJson('e${sizes.length}-$i', (e as Map<String, dynamic>)['date'] as String),
], status: 201);
});
final drafts = [
for (var i = 0; i < 1001; i++) (date: DateTime(2026, 1, 1).add(Duration(days: i)), details: _details),
];
final created = await repo.createEvents(drafts);
expect(sizes, [500, 500, 1]);
expect(created, hasLength(1001));
expect(created.first.date, DateTime(2026, 1, 1));
});
test('완료 표시는 날짜 일정과 반복 일정의 경로가 다르다', () async {
api.on('PUT', '/schedule-events/:id/completion', (r) => noContent);
api.on('DELETE', '/repeat-rules/:id/completions/:date', (r) {
expect(r.params, {'id': 'r1', 'date': '2026-10-11'});
return noContent;
});
await repo.setDone('e1', done: true);
await repo.setDone(ScheduleEvent.instanceId('r1', DateTime(2026, 10, 11)), done: false);
expect(api.sent('PUT', '/schedule-events/e1/completion'), hasLength(1));
expect(api.sent('DELETE', '/repeat-rules/r1/completions/2026-10-11'), hasLength(1));
});
test('반복 주기·내용 바꾸기는 시작한 날을 보내지 않는다', () async {
api.on('PUT', '/repeat-rules/:id', (r) {
expect(r.json!.keys, unorderedEquals(['spec', 'details']));
return ok({
'id': 'r1',
'from': '2026-10-11',
'spec': {'mode': 'daily', 'unit': 'week', 'n': 1},
'details': _detailsJson(),
});
});
final rule = RepeatRule(
id: 'r1',
spec: const RepeatSpec(RepeatPattern.every),
from: DateTime(2026, 10, 11),
details: _details,
);
final updated = await repo.updateRule(rule, const RepeatSpec(RepeatPattern.daily), _details);
expect(updated.spec.mode, RepeatPattern.daily);
});
test('아이를 지울 때 일정 정리는 서버가 이미 했다 — 아무것도 보내지 않는다', () async {
await repo.dropTarget('p1');
expect(api.requests, isEmpty);
});
test('로그인 전에는 불러오지 않는다', () async {
final signedOut = FakeApi(tokens: null);
final book = await ApiScheduleRepository(dio: signedOut.dio, session: signedOut.session).fetch();
expect(book.events, isEmpty);
expect(signedOut.requests, isEmpty);
});
}
+1 -1
View File
@@ -74,7 +74,7 @@ void main() {
Future<LocalPetRepository> repo() async {
SharedPreferences.setMockInitialValues({});
final r = LocalPetRepository(await SharedPreferences.getInstance());
await r.create(samplePet());
await r.create(samplePet(), tint: kPetTints.first);
return r;
}
+33 -4
View File
@@ -113,21 +113,50 @@ void main() {
await repo.create('pet-1', CardStyle.initial());
await repo.update(a.id, CardStyle.initial().withTextLight(true));
var cards = await repo.fetchCards();
var cards = (await repo.fetch()).cards;
expect(cards, hasLength(2));
expect(cards.first.style.textLight, isTrue);
await repo.delete(a.id);
cards = await repo.fetchCards();
cards = (await repo.fetch()).cards;
expect(cards.map((c) => c.id), isNot(contains(a.id)));
});
test('대표 명함을 아이별로 기억하고 지울 수 있다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
expect(await repo.fetchRepresentatives(), {'pet-1': a.id});
expect((await repo.fetch()).representatives, {'pet-1': a.id});
await repo.clearRepresentative('pet-1');
expect(await repo.fetchRepresentatives(), isEmpty);
expect((await repo.fetch()).representatives, isEmpty);
});
test('명함을 지우면 그 명함을 가리키던 대표 지정도 사라진다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
await repo.delete(a.id);
expect((await repo.fetch()).representatives, isEmpty);
});
test('아이를 지우면 그 아이의 명함과 대표 지정을 모두 지운다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.create('pet-2', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
await repo.dropPet('pet-1');
final book = await repo.fetch();
expect(book.cards.map((c) => c.petId), ['pet-2']);
expect(book.representatives, isEmpty);
});
test('명함 코드는 서버가 발급한 값, 없으면 명함 id 로 만든다', () {
final local = PetCard(id: 'c1', petId: 'p1', style: CardStyle.initial());
const fromServer = PetCard(id: 'c1', petId: 'p1', style: CardStyle(tint: Color(0xFFFF9B6A), tint2: Color(0xFFFF9B6A)), code: '0428-117');
expect(displayCardCode(local), petCardCode('c1'));
expect(displayCardCode(fromServer), '0428-117');
});
test('명함 JSON 을 그대로 되살릴 수 있다', () {
+5 -5
View File
@@ -120,22 +120,22 @@ void main() {
});
test('등록한 순서대로 목록에 쌓인다', () async {
await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.create(_input(name: '보리'), tint: kPetTints.first);
final pets = await repo.fetchPets();
expect(pets.map((p) => p.name), ['콩이', '보리']);
expect(pets.map((p) => p.id).toSet().length, 2);
});
test('수정하면 같은 자리의 값이 바뀐다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.create(_input(name: '보리'), tint: kPetTints.first);
await repo.update(a.id, _input(name: '콩순이'));
expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']);
});
test('삭제하면 목록에서 빠진다', () async {
final a = await repo.create(_input(name: '콩이'));
final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.delete(a.id);
expect(await repo.fetchPets(), isEmpty);
});
+1 -27
View File
@@ -105,33 +105,7 @@ void main() {
expect(found?.isRepeat, isTrue);
});
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다', () {
final target = _event('a', day, _details('산책'));
final book = ScheduleBook(events: [target]).update(
target,
_details('산책'),
spec: const RepeatSpec(RepeatPattern.daily),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.events, isEmpty);
expect(book.rules.single.from, day);
expect(book.eventsOn(DateTime(2026, 9, 30)).single.details.title, '산책');
});
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그날 완료 표시는 지운다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
final instance = rule.instanceOn(DateTime(2026, 10, 1));
final book = ScheduleBook(rules: [rule], doneIds: {instance.id}).update(
instance,
_details('산책'),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.rules, isEmpty);
expect(book.events.single.date, DateTime(2026, 10, 1));
expect(book.doneIds, isEmpty);
});
// 일반 ↔ 반복 전환 규칙은 저장소를 거치므로 schedule_notifier_test 가 검사한다
test('반복 일정을 지우면 규칙 전체가 지워진다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/schedule/data/schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
const EventDetails _walk = EventDetails(
title: '산책',
kind: EventKind.general,
category: EventCategory.walk,
targetIds: ['pet-1'],
);
final DateTime _day = DateTime(2026, 9, 30);
/// 저장소에 들어온 호출 순서를 기록한다 — 기기 저장소를 감싼다
class _RecordingRepository implements ScheduleRepository {
_RecordingRepository(this._inner);
final ScheduleRepository _inner;
final List<String> calls = [];
bool failSetDone = false;
@override
Future<ScheduleBook> fetch() => _inner.fetch();
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) {
calls.add('createEvents');
return _inner.createEvents(drafts);
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) {
calls.add('updateEvent');
return _inner.updateEvent(event, details);
}
@override
Future<void> deleteEvent(String eventId) {
calls.add('deleteEvent');
return _inner.deleteEvent(eventId);
}
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) {
calls.add('createRule');
return _inner.createRule(from, spec, details);
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) {
calls.add('updateRule');
return _inner.updateRule(rule, spec, details);
}
@override
Future<void> deleteRule(String ruleId) {
calls.add('deleteRule');
return _inner.deleteRule(ruleId);
}
@override
Future<void> setDone(String occurrenceId, {required bool done}) {
if (failSetDone) throw Exception('저장 실패');
return _inner.setDone(occurrenceId, done: done);
}
@override
Future<void> dropTarget(String targetId) => _inner.dropTarget(targetId);
}
void main() {
late ProviderContainer container;
late _RecordingRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
repo = _RecordingRepository(LocalScheduleRepository(prefs));
container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
dataModeProvider.overrideWithValue(DataMode.local),
scheduleRepositoryProvider.overrideWithValue(repo),
],
);
addTearDown(container.dispose);
await container.read(scheduleProvider.future);
});
ScheduleNotifier notifier() => container.read(scheduleProvider.notifier);
ScheduleBook book() => container.read(scheduleBookProvider);
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다 — 규칙을 먼저 만들고 일정을 지운다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final target = book().events.single;
repo.calls.clear();
await notifier().edit(target, _walk, spec: const RepeatSpec(RepeatPattern.daily));
expect(book().events, isEmpty);
expect(book().rules.single.from, _day);
expect(book().eventsOn(DateTime(2026, 10, 3)).single.details.title, '산책');
expect(repo.calls, ['createRule', 'deleteEvent']);
// 저장소에도 같은 결과가 남는다
final saved = await repo.fetch();
expect(saved.events, isEmpty);
expect(saved.rules, hasLength(1));
});
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그 규칙의 완료 표시는 지운다', () async {
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
final instance = book().eventsOn(DateTime(2026, 10, 1)).single;
await notifier().toggleDone(instance.id);
expect(book().isDone(instance.id), isTrue);
repo.calls.clear();
await notifier().edit(instance, _walk);
expect(book().rules, isEmpty);
expect(book().events.single.date, DateTime(2026, 10, 1));
expect(book().doneIds, isEmpty);
expect(repo.calls, ['createEvents', 'deleteRule']);
});
test('반복 주기만 바꾸면 같은 규칙이 남고 시작한 날도 그대로다', () async {
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
final instance = book().eventsOn(DateTime(2026, 10, 2)).single;
await notifier().edit(instance, _walk, spec: const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 2));
final rule = book().rules.single;
expect(rule.id, instance.ruleId);
expect(rule.from, _day);
expect(rule.spec.label, '2일 마다');
});
test('완료 체크를 저장하지 못하면 화면의 체크도 되돌린다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final id = book().events.single.id;
repo.failSetDone = true;
await expectLater(notifier().toggleDone(id), throwsException);
expect(book().isDone(id), isFalse);
});
test('기간 설정으로 바꾸면 새 일정을 먼저 만들고 원래 일정을 지운다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final target = book().events.single;
repo.calls.clear();
await notifier().replace(target, [
(date: DateTime(2026, 10, 1), details: _walk),
(date: DateTime(2026, 10, 2), details: _walk),
]);
expect(book().events.map((e) => e.date.day), [1, 2]);
expect(repo.calls, ['createEvents', 'deleteEvent']);
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/dio_client.dart';
import 'package:tma_app/core/api/session_store.dart';
/// 가짜 응답 — 상태 코드와 JSON 본문
typedef FakeReply = ({int status, Object? body});
/// 받은 요청 — 경로 변수(:id)와 본문을 꺼내 쓰기 쉽게 정리한다
class FakeRequest {
FakeRequest(this.options, this.params);
final RequestOptions options;
final Map<String, String> params;
String get method => options.method;
String get path => options.path;
Map<String, dynamic> get query => options.queryParameters;
/// JSON 본문 (FormData 면 null)
Map<String, dynamic>? get json => options.data is Map<String, dynamic> ? options.data as Map<String, dynamic> : null;
}
typedef FakeHandler = FutureOr<FakeReply> Function(FakeRequest request);
/// 표준 성공 응답 `{ success: true, data }`
FakeReply ok(Object? data, {int status = 200}) => (status: status, body: {'success': true, 'data': data});
/// 본문 없는 성공 (204)
const FakeReply noContent = (status: 204, body: null);
/// 표준 실패 응답 — 문구는 앱 사전이 정한다
FakeReply failure(String code, int status) => (
status: status,
body: {
'success': false,
'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'},
},
);
/// 목록 응답 `{ items, pageInfo }` 한 페이지
Map<String, Object?> page(List<Object?> items, {String? nextCursor, int? total}) => {
'items': items,
'pageInfo': {'hasNext': nextCursor != null, 'nextCursor': nextCursor, 'total': total ?? items.length},
};
/// 네트워크 없이 서버를 흉내 낸다 — `api.on('GET', '/pets/:id', (r) => ok(...))`
class FakeApi implements HttpClientAdapter {
FakeApi({AuthTokens? tokens = const AuthTokens(accessToken: 'access', refreshToken: 'refresh')}) {
session = SessionStore(MemoryTokenVault(), tokens);
client = DioClient(
baseUrl: 'http://test/api',
session: session,
onError: reported.add,
httpClientAdapter: this,
);
}
late final SessionStore session;
late final DioClient client;
final List<ApiException> reported = [];
final List<FakeRequest> requests = [];
final List<(String, RegExp, List<String>, FakeHandler)> _routes = [];
Dio get dio => client.dio;
/// 경로 패턴은 `/pets/:id` 처럼 쓴다 — 나중에 등록한 것이 먼저 맞는다
void on(String method, String pattern, FakeHandler handler) {
final names = <String>[];
final regex = RegExp(
'^${pattern.replaceAllMapped(RegExp(r':(\w+)'), (m) {
names.add(m[1]!);
return '([^/]+)';
})}\$',
);
_routes.insert(0, (method, regex, names, handler));
}
/// [method] [path] 로 온 요청들
List<FakeRequest> sent(String method, String path) =>
requests.where((r) => r.method == method && r.path == path).toList();
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
for (final (method, regex, names, handler) in _routes) {
final match = regex.firstMatch(options.path);
if (method != options.method || match == null) continue;
final request = FakeRequest(options, {for (var i = 0; i < names.length; i++) names[i]: match[i + 1]!});
requests.add(request);
final reply = await handler(request);
return ResponseBody.fromString(
reply.body == null ? '' : jsonEncode(reply.body),
reply.status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
throw StateError('등록하지 않은 요청: ${options.method} ${options.path}');
}
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,246 @@
// 서버 계약 라이브 점검 — 실제로 떠 있는 개발 서버에 앱의 서버 저장소를 붙여 본다.
//
// 단위 테스트(test/api_*_test.dart)는 응답 모양을 가정한 가짜 서버로 돈다. 이 파일은 그 가정이
// 실제 서버와 맞는지 확인한다. 서버가 필요하므로 CI(`flutter test` = test/ 폴더)에서는 돌지 않는다.
//
// docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
// cd flutter && flutter test test_live
//
// 다른 주소면 LIVE_API_URL 로 바꾼다 (예: LIVE_API_URL=http://10.0.2.2:3000/api).
// 개발용 로그인으로 새 회원을 만들고, 끝나면 탈퇴해 기록을 지운다.
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/dio_client.dart';
import 'package:tma_app/core/api/file_uploader.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
/// 1×1 PNG — 사진 올리기용
final Uint8List _png = Uint8List.fromList(const [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, //
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, //
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0x0F, 0x00, 0x00, //
0x01, 0x01, 0x00, 0x05, 0x18, 0xD8, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, //
0x42, 0x60, 0x82,
]);
PetInput _pet(String name, {String? photoPath}) => PetInput(
name: name,
species: '강아지',
breed: '말티즈',
gender: PetGender.male,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: 3.2,
weightUnit: WeightUnit.kg,
photoPath: photoPath,
allergies: const ['닭고기'],
watchConditions: const ['슬개골'],
);
EventDetails _details(String title, List<String> targets, {HourMinute? start}) =>
EventDetails(title: title, kind: EventKind.general, category: EventCategory.walk, targetIds: targets, start: start);
void main() {
final baseUrl = Platform.environment['LIVE_API_URL'] ?? 'http://localhost:3000/api';
final reported = <ApiException>[];
late SessionStore session;
late Dio dio;
late SharedPreferences prefs;
late FileUploader uploader;
late ApiAuthRepository auth;
late Directory temp;
late String photoPath;
setUpAll(() async {
// test_live/ 는 분석기가 테스트 폴더로 보지 않는다 — 이 파일도 테스트이므로 메모리 저장소를 쓴다
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
session = SessionStore(MemoryTokenVault());
dio = DioClient(baseUrl: baseUrl, session: session, onError: reported.add).dio;
uploader = FileUploader(dio, reported.add);
temp = Directory.systemTemp.createTempSync('rup-live');
photoPath = (File('${temp.path}/photo.png')..writeAsBytesSync(_png)).path;
final deviceKey = 'live-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 30)}';
auth = ApiAuthRepository(dio: dio, session: session, prefs: prefs, deviceKeys: FixedDeviceKeyStore(deviceKey));
await auth.signIn(SocialProvider.kakao);
});
tearDownAll(() async {
if (session.hasSession) await auth.deleteAccount();
temp.deleteSync(recursive: true);
});
test('로그인하면 세션·계정 종류·회원 id 가 남는다', () {
expect(session.hasSession, isTrue);
expect(auth.provider, SocialProvider.kakao);
expect(prefs.getString(kAuthUserIdKey), isNotNull);
});
test('프로필 — 닉네임·사진을 바꾸고 다시 읽는다', () async {
final profiles = ApiProfileRepository(dio: dio, session: session, uploader: uploader, prefs: prefs);
final saved = await profiles.save(UserProfile(nickname: '라이브 집사', photoPath: photoPath));
final fetched = await profiles.fetch();
expect(fetched, saved);
expect(fetched.nickname, '라이브 집사');
expect(serverFileIdOf(fetched.photoPath), isNotNull);
// 올린 사진을 Dio 로 받아 온다 (사진 표시와 같은 경로)
final bytes = await dio.get<List<int>>(
'$kFilesPath/${serverFileIdOf(fetched.photoPath)}',
options: Options(responseType: ResponseType.bytes),
);
expect(bytes.data, _png);
});
test('아이·일정·다이어리·명함이 서버와 같은 모양으로 오간다', () async {
final pets = ApiPetRepository(dio: dio, session: session, uploader: uploader);
final schedule = ApiScheduleRepository(dio: dio, session: session);
final diaries = ApiDiaryRepository(
dio: dio,
session: session,
uploader: uploader,
myUserId: () => prefs.getString(kAuthUserIdKey),
);
final cards = ApiPetCardRepository(dio: dio, session: session, uploader: uploader);
// ── 아이 ──
final kong = await pets.create(_pet('콩이', photoPath: photoPath), tint: kPetTints[0]);
final dubu = await pets.create(_pet('두부'), tint: kPetTints[1]);
expect(kong.photoPath, startsWith(kServerFilePrefix));
expect(kong.allergies, ['닭고기']);
final edited = await pets.update(dubu.id, _pet('두부두부'));
expect(edited.name, '두부두부');
expect(edited.tint, kPetTints[1]);
final reordered = await pets.reorder([dubu.id, kong.id]);
expect(reordered.map((p) => p.id), [dubu.id, kong.id]);
// ── 일정 ──
final today = DateTime.now();
final day = DateTime(today.year, today.month, today.day);
final events = await schedule.createEvents([
(date: day, details: _details('산책', [kong.id, 'self'], start: const HourMinute(8, 30))),
(date: day, details: _details('병원', [dubu.id])),
]);
final rule = await schedule.createRule(
day,
const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2),
_details('목욕', [kong.id]),
);
await schedule.setDone(events.first.id, done: true);
await schedule.setDone(ScheduleEvent.instanceId(rule.id, day), done: true);
var book = await schedule.fetch();
expect(book.events, hasLength(2));
expect(book.isDone(events.first.id), isTrue);
expect(book.isDone(ScheduleEvent.instanceId(rule.id, day)), isTrue);
expect(book.eventsOn(day.add(const Duration(days: 14))).single.details.title, '목욕');
expect(book.events.firstWhere((e) => e.id == events.first.id).details.start, const HourMinute(8, 30));
final updatedRule = await schedule.updateRule(
rule,
const RepeatSpec(RepeatPattern.daily),
_details('목욕', [kong.id]),
);
expect(updatedRule.from, day);
// ── 다이어리 ──
final diary = await diaries.createDiary(
name: '콩이의 하루',
targetIds: [kong.id],
ribbon: kRibbonColors[2],
tint: kDiaryTints[0],
coverPath: photoPath,
);
expect(diary, isNotNull);
final post = await diaries.addPost(
diaryId: diary!.id,
date: day,
text: '산책했어요',
mood: Mood.happy,
photoPath: photoPath,
);
final comment = await diaries.addComment(post.id, '귀여워');
await diaries.updatePostText(post, '산책 다녀왔어요');
await diaries.updateComment(comment, '정말 귀여워');
await diaries.updateDiary(diary.copyWith(mutedNotices: {DiaryNotice.reminder}));
final diaryBook = await diaries.fetch();
expect(diaryBook.wroteOn(diary.id, day), isTrue);
expect(diaryBook.posts.single.text, '산책 다녀왔어요');
expect(diaryBook.comments.single.text, '정말 귀여워');
expect(diaryBook.diaries.single.mutedNotices, {DiaryNotice.reminder});
expect(diaryBook.diaries.single.coverPath, startsWith(kServerFilePrefix));
// ── 명함 ──
final assetCard = await cards.create(
kong.id,
CardStyle.forPet(kong.tint).withBackgroundImage('assets/svg/card_bg/bg_1.svg'),
);
final photoCard = await cards.create(kong.id, CardStyle.initial().withBackgroundImage(photoPath));
expect(assetCard.code, matches(RegExp(r'^\d{4}-\d{3}$')));
expect(assetCard.style.nameBgImage, 'assets/svg/card_bg/bg_1.svg');
expect(photoCard.style.nameBgImage, startsWith(kServerFilePrefix));
await cards.setRepresentative(kong.id, photoCard.id);
expect((await cards.fetch()).representatives, {kong.id: photoCard.id});
// ── 보관·삭제에 따른 서버 정리 ──
final archived = await pets.archive(
dubu.id,
PetArchive(from: DateTime(2021, 9, 14), to: day, archivedAt: DateTime.now(), memo: '고마워'),
);
expect(archived.archive?.memo, '고마워');
book = await schedule.fetch();
expect(book.events.map((e) => e.details.title), ['산책'], reason: '보관한 아이만 대상인 일정은 지운다');
expect((await pets.restore(dubu.id)).isArchived, isFalse);
await pets.delete(kong.id);
book = await schedule.fetch();
expect(book.events.single.details.targetIds, ['self'], reason: '지운 아이만 대상에서 뗀다');
expect(book.rules, isEmpty, reason: '대상이 남지 않은 반복 일정은 지운다');
expect((await cards.fetch()).cards, isEmpty, reason: '아이의 명함은 함께 지운다');
expect((await diaries.fetch()).diaries.single.targetIds, isEmpty, reason: '다이어리는 남기고 대상에서만 뗀다');
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
});
test('규칙 위반은 사전 문구의 에러 코드로 돌아온다', () async {
final diaries = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => null);
final diary = (await diaries.fetch()).diaries.first;
final today = DateTime.now();
await expectLater(
diaries.addPost(diaryId: diary.id, date: DateTime(today.year, today.month, today.day), text: '두 번째'),
throwsA(isA<DioException>().having((e) => (e.error! as ApiException).code, 'code', 'DIARY_002')),
);
});
}