generated from ttipo/project-base
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5331362215 | |||
| 373d50576a | |||
| b9c351d7cf | |||
| 42abefcc66 | |||
| 9cb660a0ac | |||
| 2c1d1cc97d | |||
| a09086daf7 | |||
| 60efc65ee0 | |||
| 100dbe3a35 | |||
| 00660c7d62 |
@@ -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`
|
||||
|
||||
@@ -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`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 도메인 코드 추가
|
||||
|
||||
|
||||
@@ -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]] 참조.
|
||||
|
||||
@@ -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)가 여전히 중요하다.
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
|
||||
@@ -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절).
|
||||
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
|
||||
|
||||
@@ -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 에서는 돌지 않는다).
|
||||
|
||||
@@ -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` 로 로드).
|
||||
|
||||
@@ -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/` 에 추가
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 을 그대로 되살릴 수 있다', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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('산책'));
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user