diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 34540b4..4d5bea3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,9 +1,18 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { localProperties.load(it) } +} +val kakaoNativeAppKey: String = localProperties.getProperty("kakao.nativeAppKey") ?: "" + android { namespace = "com.livith.livith" compileSdk = flutter.compileSdkVersion @@ -23,6 +32,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + manifestPlaceholders["kakaoNativeAppKey"] = kakaoNativeAppKey } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index de0b823..7e65d61 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + + + + + + + + + + + + + diff --git a/assets/icons/home_enabled.svg b/assets/icons/home_enabled.svg new file mode 100644 index 0000000..09f1d2c --- /dev/null +++ b/assets/icons/home_enabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/my_disabled.svg b/assets/icons/my_disabled.svg new file mode 100644 index 0000000..7408906 --- /dev/null +++ b/assets/icons/my_disabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/my_enabled.svg b/assets/icons/my_enabled.svg new file mode 100644 index 0000000..904a7e9 --- /dev/null +++ b/assets/icons/my_enabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..74e34d9 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/2.0x/livith_logo.png b/assets/images/2.0x/livith_logo.png new file mode 100644 index 0000000..6fb8d8b Binary files /dev/null and b/assets/images/2.0x/livith_logo.png differ diff --git a/assets/images/3.0x/livith_logo.png b/assets/images/3.0x/livith_logo.png new file mode 100644 index 0000000..f28d926 Binary files /dev/null and b/assets/images/3.0x/livith_logo.png differ diff --git a/assets/images/livith_logo.png b/assets/images/livith_logo.png new file mode 100644 index 0000000..2c2071b Binary files /dev/null and b/assets/images/livith_logo.png differ diff --git a/docs/plans/LIVD-411-assumptions.md b/docs/plans/LIVD-411-assumptions.md new file mode 100644 index 0000000..eeca900 --- /dev/null +++ b/docs/plans/LIVD-411-assumptions.md @@ -0,0 +1,36 @@ +# LIVD-411 이관 중 임의 결정/가정 (M4~M8) + +자율 진행 중 확실하지 않아 임의로 처리한 항목. 전체 완료 후 한 번에 확인받는다. + +## API/JSON +- 서버 JSON 키는 iOS DTO 기준 camelCase로 가정(`posterUrl`, `startDate`, `daysLeft` 등). 실제 응답과 다르면 통합 시 매퍼 조정 필요. +- 페이지네이션(cursor/size)은 단순 1페이지 조회로 우선 구현하고 무한스크롤은 후속. + +## UI +- iOS 커스텀 아이콘/이미지 에셋은 Material 아이콘·임시 텍스트로 대체. 정밀 에셋 교체는 별도. +- 화면별 세부 간격/모션은 iOS 근사치로 구현. 픽셀 단위 정합은 후속 QA. + +## 범위 +- 소셜 로그인: stub 유지(키 확보 후 교체). +- FCM/Amplitude/딥링크(M8): SDK 키·네이티브 설정 의존 → 인터페이스+stub로 흐름만. + +## 후속 작업 진행(추가 완료) +- 아이콘 에셋: **로고·탭바 아이콘(SVG)** 교체 완료. 나머지(notice/back/check/caution 등)는 SVG/PNG 혼재로 후속. +- **콘서트 상세 4탭 완성**: 아티스트/정보/셋리스트/커뮤니티 모두 구현. +- **알림 설정 화면** 추가(마케팅 동의만 stub 연동, 나머지 토글 UI). + +## 마일스톤별 축소/후속 처리(잔여) +- **M6 콘서트 상세**: 굿즈(MD), 콘서트 문화/팬팁 등 부가 정보 미구현. +- **M7 마이/설정**: 선호도(장르/아티스트) 수정, 공지사항은 후속. +- **M5 탐색**: 배너 캐러셀·정렬 옵션·필터 바텀시트는 생략, 키워드+장르 필터만. +- **M4 홈**: 홈 섹션 API(`/home/sections`) 대신 추천+관심 콘서트로 단순 구성. + +## 검증 한계 +- 소셜 로그인 stub이라 실제 로그인 후 화면(홈/탐색/상세/마이)은 staging API 응답이 있어야 통합 확인 가능. 현재는 빌드·렌더·라우팅·단위 테스트로 검증. +- JSON 키(예: `posterUrl`, `hasPreferredGenre`, `tempUserData`)는 iOS DTO 기준 추정 — 실제 응답과 대조 필요. + +## 확인 필요(우선순위) +1. 실제 API JSON 키 스펙(특히 콘서트/셋리스트/유저 응답) 대조 +2. 소셜 로그인 네이티브 키(카카오 앱키, 애플 설정) → SDK 실연동 +3. FCM/Amplitude 키 → stub 교체 +4. 후속 화면(아티스트/커뮤니티/알림설정 등) 진행 여부 diff --git a/docs/plans/LIVD-411-ios-flutter-migration.md b/docs/plans/LIVD-411-ios-flutter-migration.md new file mode 100644 index 0000000..f3477c1 --- /dev/null +++ b/docs/plans/LIVD-411-ios-flutter-migration.md @@ -0,0 +1,102 @@ +# LIVD-411 iOS 프로젝트 플러터로 이관 + +## 배경 +- Livith는 K-pop 콘서트 정보 플랫폼(콘서트 일정·셋리스트·가사번역·응원법·커뮤니티)으로, 현재 iOS(Swift/SwiftUI, Tuist 14개 모듈, ~473 파일)로 구현되어 있다. +- 이를 Flutter(Riverpod + MVVM)로 이관한다. iOS는 화면 74개, 도메인 엔티티 ~30개, API 엔드포인트 ~60개 규모이므로 단일 PR로 불가능하다. +- 따라서 전체를 마일스톤으로 분할하고, 본 문서는 **전체 로드맵 + 마일스톤 1(기반 + 디자인시스템)** 을 다룬다. 이후 마일스톤은 별도 plan 문서로 분리한다. + +## 목표 +- 전체 이관의 마일스톤 로드맵을 확정한다. +- **마일스톤 1 완료 시**: iOS의 디자인 토큰(색상/타이포)과 핵심 공통 위젯, 네트워킹/DI 토대가 Flutter에 구축되어, 이후 화면 이관이 이 토대 위에서 진행 가능한 상태가 된다. + +## 전체 로드맵 (마일스톤) +| # | 마일스톤 | 범위 | 상태 | +|---|----------|------|------| +| **M1** | **기반 + 디자인시스템** | 색상/타이포/폰트, 공통 위젯, 네트워킹·DI 토대, 폴더 구조 | **본 문서** | +| M2 | 도메인 모델 + 네트워킹 | ~30 엔티티, API 클라이언트, ~60 엔드포인트, 토큰/인증 인터셉터, 로컬 저장소 | 예정 | +| M3 | 인증 / 온보딩 | 로그인(카카오/애플) → 약관 → 닉네임 → 선호 장르/아티스트 | 예정 | +| M4 | 홈 탭 | 홈, 관심 공연, 공지, 선호도 수정 | 예정 | +| M5 | 탐색 탭 | 탐색, 검색, 필터 바텀시트 | 예정 | +| M6 | 콘서트 상세 | 콘서트 상세(4탭), 셋리스트, 가사, 굿즈 | 예정 | +| M7 | 마이 탭 | 마이페이지, 설정, 알림설정, 회원탈퇴 | 예정 | +| M8 | 외부 연동 / 마무리 | FCM, Amplitude, 딥링크, 위젯 검토 | 예정 | + +> M2~M8은 각각 시작 시점에 별도 plan 문서(`docs/plans/LIVD-XXX-*.md`)를 작성하고 확인받는다. + +--- + +## 마일스톤 1 — 작업 항목 + +- [ ] **1. 폰트 에셋 도입** + - iOS 레포에서 Noto Sans KR 4종(Bold/SemiBold/Medium/Regular, ttf), Pretendard 9종(otf)을 `assets/fonts/`로 복사 + - `pubspec.yaml`의 `fonts:` 섹션에 family `NotoSansKR`, `Pretendard` 등록 +- [ ] **2. 색상 토큰** (`lib/core/theme/livith_colors.dart`) + - iOS `LivithColor` 12색을 `Color` 상수로 정의 (아래 표) +- [ ] **3. 타이포그래피** (`lib/core/theme/livith_typography.dart`) + - iOS `Notosans` 13스타일을 `TextStyle`로 정의 (size/weight/height/letterSpacing) +- [ ] **4. 앱 테마** (`lib/core/theme/livith_theme.dart`) + - 다크 기반 `ThemeData` 구성(배경 Black100), `app.dart`에 적용 +- [ ] **5. 핵심 공통 위젯** (`lib/views/widgets/`) — iOS DesignSystem 대응 + - 우선순위: 버튼 계열(LivithButton/ActionButton/TextButton), 카드(LivithCard), 칩(LivithChip), 네비게이션 헤더(LivithNavigationView), 세그먼트 탭바, 모달(LivithModal/DangerModal), 토스트, 비동기 이미지(AsyncImageView→cached_network_image), FlowLayout(자동 줄바꿈) + - 이번 마일스톤은 토큰+위젯 골격 우선. 화면별 특수 컴포넌트는 해당 화면 마일스톤에서 추가 +- [ ] **6. 네트워킹 토대** (`lib/services/`, `lib/providers/`) + - Dio 기반 `ApiClient` Service + `Provider` 노출 + - 토큰 인터셉터/401 갱신 골격(실제 토큰 저장은 M2) + - **TDD 적용**: 요청 빌더/인터셉터 로직은 실패 테스트 → 구현 +- [ ] **7. 폴더 구조 정비** + - `lib/core/theme/`, 위젯 디렉터리 정리, 기존 `home_screen.dart`의 임시 내용을 디자인시스템 미리보기로 대체(검증용) + +### 색상 토큰 (iOS → Flutter) +| 토큰 | HEX | 토큰 | HEX | +|------|-----|------|-----| +| black100 | #14171B | black5 | #F2F4F6 | +| black90 | #222831 | white100 | #FFFFFF | +| black80 | #2F3745 | yellow30 | #FFFF97 | +| black50 | #808794 | yellow60 | #FFEB56 | +| black30 | #DBDCDF | caution100 | #E11936 | +| original | #CAD0FF | translation | #FFBAB4 | + +### 타이포 스타일 (Noto Sans KR, kerning = size × −5%) +| 스타일 | weight | size | height(배) | +|--------|--------|------|-----------| +| title | Bold | 26 | 1.38 | +| head{Semibold/Medium/Regular} | SemiBold/Medium/Regular | 22 | 1.38 | +| body1Semibold | SemiBold | 18 | 1.38 | +| body2{Semibold/Medium/Regular} | - | 16 | 1.38 | +| body3{Semibold/Medium/Regular} | - | 15 | 1.38 | +| body4{Semibold/Medium/Regular} | - | 14 | 1.38 | +| caption1Bold/Semibold | Bold/SemiBold | 12 | 1.28 | +| caption1Regular | Regular | 12 | 1.18 | +| caption2{Semibold/Regular} | SemiBold/Regular | 10 | 1.18 | + +## 영향 범위 +- `pubspec.yaml` (의존성: dio, cached_network_image / fonts 섹션) +- `assets/fonts/` (신규) +- `lib/core/theme/` (신규: livith_colors / livith_typography / livith_theme) +- `lib/views/widgets/` (공통 위젯 다수) +- `lib/services/`, `lib/providers/` (ApiClient 토대) +- `lib/app.dart`, `lib/views/screens/home_screen.dart` (테마 적용/미리보기) +- `test/` (네트워킹 토대 단위 테스트) + +## 기술 결정 +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| HTTP 클라이언트 | dio / http | **dio** | 인터셉터·토큰 자동 갱신·취소 토큰 필요 (iOS의 인터셉터 구조 대응) | +| 이미지 로딩 | cached_network_image / 기타 | **cached_network_image** | iOS Kingfisher의 URL 캐싱 대응 | +| 폰트 도입 | 에셋 직접 포함 / google_fonts | **에셋 직접 포함** | iOS와 동일 ttf/otf 사용으로 렌더링 일치, Pretendard는 google_fonts 미제공 | +| 색상/타이포 표현 | 상수 클래스 / ThemeExtension | **상수 클래스 + ThemeData 병행** | iOS가 named token 직접 참조 방식이라 이행 단순. 추후 ThemeExtension 검토 | +| 라우팅 | Navigator / go_router | **go_router** (M3에서 확정) | 화면 74개·탭+스택 구조라 선언적 라우팅 유리. M1은 토대만, 실제 도입은 인증 마일스톤 | +| 테마 모드 | 다크 고정 / 라이트 지원 | **다크 고정** | iOS가 Black100 배경의 다크 단일 테마 | + +## 주의 사항 +- `Model`에 `flutter/material.dart`·`flutter_riverpod` import 금지. 색상/타이포는 `core/theme`(UI 레이어)에 두므로 무방. +- `Service`(ApiClient)에 `flutter_riverpod` import 금지 — Provider는 `lib/providers/`에서만. +- 네트워킹 토대는 **TDD 대상**: red → green → refactor 순서 준수. 디자인 토큰/순수 위젯은 위젯 테스트 선택. +- 폰트 라이선스(Noto Sans KR=OFL, Pretendard=OFL) 확인 — 재배포 가능. 라이선스 파일도 함께 포함. +- 마일스톤 1은 화면 동작이 아닌 "토대" 구축이므로, 검증은 디자인시스템 미리보기 화면으로 수행. +- 진행 중 실패/방향 전환 발생 시 `docs/troubleshooting/LIVD-411-*.md`에 즉시 기록. + +## 검증 방법 +- `flutter analyze` 무경고 +- `flutter test` 통과 (네트워킹 토대 단위 테스트 포함) +- 에뮬레이터(Pixel 7 / API 36)에서 디자인시스템 미리보기 화면 실행 → 색상/타이포/공통 위젯이 iOS와 시각적으로 일치하는지 수동 확인 diff --git a/docs/plans/LIVD-411-m2-domain-network.md b/docs/plans/LIVD-411-m2-domain-network.md new file mode 100644 index 0000000..c82fa20 --- /dev/null +++ b/docs/plans/LIVD-411-m2-domain-network.md @@ -0,0 +1,57 @@ +# LIVD-411 마일스톤 2 — 도메인 모델 + 네트워킹 + +## 배경 +- 마일스톤 1에서 디자인시스템과 `dio` 네트워킹 토대(`dioProvider`, `AuthInterceptor`, 인메모리 `TokenStore`)를 구축했다. +- 화면 이관(M3~)을 시작하려면 공통 네트워킹 인프라(응답 래퍼·에러 매핑·토큰 영속화)와 인증/유저 도메인이 먼저 필요하다. +- iOS는 도메인 엔티티 ~30개, API ~60개를 갖지만, 한 번에 전부 이식하면 사용되지 않는 코드가 대량 생긴다. 따라서 **공통 인프라 + 인증/유저 도메인까지만** M2에서 다루고, 나머지 도메인 모델·Service는 그것을 사용하는 화면 마일스톤에서 추가한다(YAGNI). + +## 목표 +- 모든 도메인 Service가 공유할 네트워킹 인프라(응답 디코딩, 에러 매핑, 토큰 영속/갱신)를 완성한다. +- 인증/온보딩(M3)에 필요한 인증·유저 도메인 모델과 Service를 이식한다. +- M2 완료 시 M3(로그인/온보딩) 화면을 ViewModel→Service→Model 흐름으로 구현할 수 있다. + +## 작업 항목 +- [ ] **1. 공통 응답/에러 모델** + - iOS `ServerResponse`(status/message/data) 대응 `ApiResponse` 디코딩 헬퍼 + - 도메인 실패를 표현하는 `Failure` sealed class (네트워크/인증/서버/파싱) + - Dio 에러 → `Failure` 매핑 (TDD) +- [ ] **2. 토큰 영속화** + - `flutter_secure_storage` 기반 `SecureTokenStore`로 M1 `InMemoryTokenStore` 교체 (`tokenStoreProvider` override) + - 401 응답 시 refresh → 재요청하는 인터셉터 로직 (TDD) +- [ ] **3. 환경별 baseURL** + - `--dart-define`(`LIVITH_API_BASE_URL`) 기반 설정 정리, 실제 dev/prod URL 반영 +- [ ] **4. 인증/유저 도메인 모델** + - `User`, `SocialProvider`, `TempUser`, `SignupInfo`, `Nickname` 등 불변 모델 + `fromJson` (TDD) +- [ ] **5. 인증/유저 Service** + - `AuthService`: apple/kakao 로그인, signup, logout, withdraw, 닉네임 중복확인 + - `UserService`: `GET /users/me`, 닉네임 수정 + - 응답 DTO → 도메인 Model 매핑 (TDD) +- [ ] **6. Provider 등록** + - `authServiceProvider`, `userServiceProvider`를 `lib/providers/`에 노출 + +## 영향 범위 +- `pubspec.yaml` (flutter_secure_storage, 코드젠 도입 시 json_serializable/build_runner) +- `lib/models/` (인증/유저 도메인 모델) +- `lib/services/` (auth_service, user_service, secure_token_store, api_response, failure) +- `lib/providers/` (authServiceProvider, userServiceProvider, tokenStore override) +- `test/` (모델 fromJson, 에러 매핑, 토큰 갱신, Service 파싱 테스트) + +## 기술 결정 +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| JSON 직렬화 | 수동 fromJson / json_serializable | **수동 fromJson** | M2 범위 모델 수가 적고(인증/유저), 코드젠 의존성·빌드 단계를 미루는 편이 단순. 모델이 급증하는 화면 마일스톤에서 json_serializable 재검토 | +| 토큰 저장 | flutter_secure_storage / shared_preferences | **flutter_secure_storage** | iOS Keychain 대응, 토큰은 민감정보 | +| 도메인/DTO 분리 | 통합 / Mapper 분리 | **형식 다를 때만 분리** | architecture.md 준수. 응답 구조와 도메인이 같으면 단일 Model | +| 에러 표현 | Exception / sealed Failure | **sealed Failure** | code-convention의 Result/Either 지향과 정합, AsyncValue.error 매핑 용이 | + +## 주의 사항 +- `Model`에 `flutter/material.dart`·`dio`·`flutter_riverpod` import 금지. +- `Service`에 `flutter_riverpod` import 금지 (Provider 파일에서만). +- 토큰 갱신 인터셉터는 무한 재요청 방지(refresh 실패 시 로그아웃 처리) 로직 포함. +- Model `fromJson`, Mapper, 에러 매핑, 토큰 갱신은 **TDD 대상**. Dio 조립/Provider 배선은 예외. +- 실제 API 응답 키는 iOS DTO(`Projects/Data/*/Model`)를 근거로 맞춘다. + +## 검증 방법 +- `flutter analyze` 무경고 +- `flutter test` 통과 (모델 fromJson, 에러 매핑, 토큰 갱신, Service 파싱 단위 테스트) +- M3 착수 시 실제 로그인 플로우로 통합 확인 diff --git a/docs/plans/LIVD-411-m3-auth-onboarding.md b/docs/plans/LIVD-411-m3-auth-onboarding.md new file mode 100644 index 0000000..93c23f0 --- /dev/null +++ b/docs/plans/LIVD-411-m3-auth-onboarding.md @@ -0,0 +1,41 @@ +# LIVD-411 마일스톤 3 — 인증 / 온보딩 + +## 배경 +- M1(디자인시스템), M2(인증/유저 모델·Service·토큰)를 토대로 첫 사용자 플로우인 로그인/온보딩을 구현한다. +- iOS 흐름: 로그인(카카오/애플) → 약관 동의 → 닉네임 설정 → 선호 장르 → 선호 아티스트 → 가입 완료 → 메인. + +## 목표 +- 로그인~온보딩 화면과 ViewModel, 라우팅을 완성해 가입/로그인 흐름이 동작한다. +- 선호 장르/아티스트 조회에 필요한 모델·Service를 추가한다(M3 범위에서 필요한 만큼). + +## 작업 항목 +- [ ] **1. 라우팅(go_router) 도입** — 라우트 정의, 인증 상태 기반 리다이렉트 +- [ ] **2. 인증 상태 ViewModel** — `AuthNotifier`(로그인 여부/토큰 로드/로그아웃), 앱 시작 시 `SecureTokenStore.load` +- [ ] **3. 소셜 로그인 추상화** — `SocialAuthService` 인터페이스(애플/카카오 토큰 획득). **네이티브 키가 필요하므로 M3는 인터페이스 + stub 구현**, 실제 SDK(kakao_flutter_sdk/sign_in_with_apple) 연동은 키 설정 후 교체 +- [ ] **4. 로그인 화면 + ViewModel** — 소셜 버튼 → AuthService 로그인 → 기존/신규 분기 +- [ ] **5. 약관 동의 화면** +- [ ] **6. 닉네임 설정 화면 + ViewModel** — `Nickname` 검증 + 중복확인(`isNicknameAvailable`) +- [ ] **7. 선호 장르 화면** — `/genres` 조회(Genre 모델·PreferenceService 추가) +- [ ] **8. 선호 아티스트 화면 + 가입** — `/search/artists` 조회, `SignupInfo`로 `signup` 호출 +- [ ] **9. 검증** — analyze/test, 에뮬레이터 흐름 확인(소셜 로그인은 stub 토큰으로) + +## 영향 범위 +- `pubspec.yaml` (go_router; 추후 kakao_flutter_sdk/sign_in_with_apple) +- `lib/routes/`, `lib/view_models/`, `lib/views/screens/`, `lib/models/`(Genre/Artist), `lib/services/`(social_auth, preference), `lib/providers/` +- `test/` + +## 기술 결정 +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| 라우팅 | Navigator / go_router | **go_router** | 인증 리다이렉트·다중 화면에 선언적 라우팅 유리(M1 결정 이행) | +| 소셜 로그인 SDK | 즉시 연동 / 인터페이스+stub | **인터페이스+stub** | 카카오/애플 네이티브 키·콘솔 설정이 외부 의존. 흐름·UI를 먼저 완성하고 키 확보 후 실연동 | +| 온보딩 상태 공유 | 화면별 분리 / 온보딩 전역 Notifier | **온보딩 전역 Notifier** | 닉네임·장르·아티스트 선택을 마지막 signup까지 누적해야 함 | + +## 주의 사항 +- 소셜 로그인 실제 토큰 획득은 stub. 실제 키 연동 지점은 `SocialAuthService` 구현 교체로 국한한다. +- ViewModel·모델·Service·매퍼는 TDD. 화면 위젯 배선과 SDK 연결은 예외 허용. +- 실제 API 서버 URL(`LIVITH_API_BASE_URL`)이 없으면 통합 확인은 stub/모의로 제한된다. + +## 검증 방법 +- `flutter analyze` 무경고, `flutter test` 통과(ViewModel/모델 단위 테스트) +- 에뮬레이터에서 로그인(stub)→온보딩→가입 화면 전환 확인 diff --git a/docs/troubleshooting/LIVD-411-ios-flutter-migration.md b/docs/troubleshooting/LIVD-411-ios-flutter-migration.md new file mode 100644 index 0000000..2024718 --- /dev/null +++ b/docs/troubleshooting/LIVD-411-ios-flutter-migration.md @@ -0,0 +1,66 @@ +# LIVD-411 iOS 프로젝트 플러터로 이관 - 트러블슈팅 + +## 기록 + +### 2026-05-23 13:20 - 검색 genre 파라미터 형식 & http 포스터 로딩 실패 + +**상황** +- 개발 토큰을 `--dart-define=LIVITH_DEV_TOKEN`으로 주입해 에뮬레이터에서 실제 staging 데이터로 탐색/검색을 통합 확인했다. + +**문제** +- 장르 선택 검색이 무한 로딩됐다. `/search/concerts?genre=1` 호출이 `400 (genre는 JPOP|... 중 하나여야 해요)`였다. +- 콘서트 포스터가 회색(미표시)이었다. + +**원인** +- 검색 `genre` 파라미터는 장르 **ID(int)** 가 아니라 장르 **이름(String, "JPOP" 등)** 을 받는다. +- 포스터 URL이 `http://`(kopis.or.kr)인데 Android 9+가 cleartext HTTP를 기본 차단한다. + +**해결** +- `SearchQuery.genreIdList`(int) → `genreNameList`(String)로 변경하고 `ExploreScreen`이 `genre.name`을 전송하도록 수정. +- `AndroidManifest.xml`에 `android:usesCleartextTraffic="true"` 추가. + +**교훈** +- 검색/필터 파라미터 타입(ID vs 코드명)은 실제 API로 확인한다. 외부 이미지가 http면 cleartext 허용 또는 https 프록시가 필요하다. + + +### 2026-05-23 13:00 - 실제 staging API 응답과 모델 키 불일치 + +**상황** +- 테스트 토큰으로 staging API(`/users/me`, `/genres`, `/search/concerts`, `/concerts/{id}`, `.../artist`, `.../comments`)를 호출해 모델 fromJson 키를 대조했다. + +**문제** +- 콘서트 포스터 키가 `posterUrl`이 아니라 `poster`였다. +- 아티스트 응답의 이름/소개 키가 `name`/`introduction`이 아니라 `artist`/`detail`이었다. +- `/search/concerts`, `/concerts/{id}/comments`는 `data`가 `{ data: [...], cursor, totalCount }`로 한 단계 더 중첩되어 있었다(추천/셋리스트/장르는 `data` 직접 배열). + +**원인** +- iOS DTO의 Swift 프로퍼티명을 서버 JSON 키로 추정했으나 실제 키와 달랐고, 검색/댓글은 페이지네이션 래퍼가 추가로 있었다. + +**해결** +- `Concert.fromJson` 포스터 키를 `poster`로, `ConcertArtist.fromJson`을 `artist`/`detail`로 수정. +- `SearchService`/`CommentService`의 응답 파싱을 `data.data` 중첩 구조에 맞게 수정. + +**교훈** +- JSON 키는 추정하지 말고 실제 응답으로 대조한다. 목록 응답은 페이지네이션 래퍼 중첩 여부를 먼저 확인한다. + + +### 2026-05-22 19:03 - LivithChip이 가로 전체 너비로 늘어남 + +**상황** +- 디자인시스템 미리보기 화면에서 `LivithChip`을 `Wrap` 안에 배치하고 에뮬레이터로 시각 검증했다. + +**문제** +- 칩이 콘텐츠 크기로 줄어들지 않고 가로 전체 너비를 차지했다. iOS는 콘텐츠 크기(hug)로 표시된다. + +**원인** +- `Container`에 `alignment`(또는 `height`)를 지정하면 부모가 주는 제약을 가능한 한 채우도록 확장된다. `Wrap`이 loose 제약을 주는 상황에서 `alignment: Alignment.center`가 칩을 가로로 늘렸다. + +**해결** +- `Container`의 `alignment`와 고정 `height: 30`을 제거하고 `padding`만으로 크기를 결정하도록 변경했다. iOS 스펙의 높이 30은 수평/수직 패딩으로 자연히 충족된다. + +**교훈** +- 콘텐츠 크기로 hug되어야 하는 위젯에는 `Container`의 `alignment`/`height`를 지정하지 않는다. 수직 정렬이 필요하면 패딩 또는 `IntrinsicHeight`/`Center`를 자식 쪽에서 다룬다. + +--- + + diff --git a/lib/app.dart b/lib/app.dart index 0f38696..f8a0ada 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:livith/routes/routes.dart'; -import 'package:livith/views/screens/home_screen.dart'; +import 'package:livith/core/theme/livith_theme.dart'; +import 'package:livith/routes/app_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,16 +10,10 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return MaterialApp( + return MaterialApp.router( title: 'Livith', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - initialRoute: Routes.home, - routes: { - Routes.home: (_) => const HomeScreen(), - }, + theme: LivithTheme.dark, + routerConfig: ref.watch(routerProvider), ); } } diff --git a/lib/core/theme/livith_colors.dart b/lib/core/theme/livith_colors.dart new file mode 100644 index 0000000..17ba68c --- /dev/null +++ b/lib/core/theme/livith_colors.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +/// Livith 디자인 시스템 색상 토큰. +/// +/// iOS `LivithDesignSystem`의 `LivithColor`(Asset Catalog)와 동일한 HEX 값을 사용한다. +abstract final class LivithColors { + const LivithColors._(); + + static const Color black100 = Color(0xFF14171B); + static const Color black90 = Color(0xFF222831); + static const Color black80 = Color(0xFF2F3745); + static const Color black50 = Color(0xFF808794); + static const Color black30 = Color(0xFFDBDCDF); + static const Color black5 = Color(0xFFF2F4F6); + static const Color white100 = Color(0xFFFFFFFF); + static const Color yellow30 = Color(0xFFFFFF97); + static const Color yellow60 = Color(0xFFFFEB56); + static const Color caution100 = Color(0xFFE11936); + static const Color original = Color(0xFFCAD0FF); + static const Color translation = Color(0xFFFFBAB4); +} diff --git a/lib/core/theme/livith_theme.dart b/lib/core/theme/livith_theme.dart new file mode 100644 index 0000000..692833e --- /dev/null +++ b/lib/core/theme/livith_theme.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +/// Livith 앱 테마. +/// +/// iOS와 동일하게 Black100 배경의 다크 단일 테마를 사용한다. +abstract final class LivithTheme { + const LivithTheme._(); + + static ThemeData get dark { + const colorScheme = ColorScheme.dark( + primary: LivithColors.yellow60, + onPrimary: LivithColors.black100, + secondary: LivithColors.yellow30, + surface: LivithColors.black90, + onSurface: LivithColors.white100, + error: LivithColors.caution100, + ); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + fontFamily: LivithTextStyles.fontFamily, + scaffoldBackgroundColor: LivithColors.black100, + colorScheme: colorScheme, + textTheme: const TextTheme( + titleLarge: LivithTextStyles.title, + titleMedium: LivithTextStyles.headSemibold, + bodyLarge: LivithTextStyles.body1Semibold, + bodyMedium: LivithTextStyles.body2Regular, + bodySmall: LivithTextStyles.body4Regular, + labelSmall: LivithTextStyles.caption1Regular, + ).apply( + bodyColor: LivithColors.white100, + displayColor: LivithColors.white100, + ), + ); + } +} diff --git a/lib/core/theme/livith_typography.dart b/lib/core/theme/livith_typography.dart new file mode 100644 index 0000000..5ada5e9 --- /dev/null +++ b/lib/core/theme/livith_typography.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +/// Livith 디자인 시스템 타이포그래피 토큰. +/// +/// iOS `LivithDesignSystem`의 `Font.Notosans`와 동일한 크기/굵기/행간을 사용한다. +/// 자간(letterSpacing)은 iOS와 동일하게 `fontSize * -0.05`로 계산한다. +abstract final class LivithTextStyles { + const LivithTextStyles._(); + + static const String fontFamily = 'NotoSansKR'; + + static const TextStyle title = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w700, + fontSize: 26, + height: 1.38, + letterSpacing: 26 * -0.05, + ); + + static const TextStyle headSemibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 22, + height: 1.38, + letterSpacing: 22 * -0.05, + ); + + static const TextStyle headMedium = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w500, + fontSize: 22, + height: 1.38, + letterSpacing: 22 * -0.05, + ); + + static const TextStyle headRegular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 22, + height: 1.38, + letterSpacing: 22 * -0.05, + ); + + static const TextStyle body1Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 18, + height: 1.38, + letterSpacing: 18 * -0.05, + ); + + static const TextStyle body2Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1.38, + letterSpacing: 16 * -0.05, + ); + + static const TextStyle body2Medium = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w500, + fontSize: 16, + height: 1.38, + letterSpacing: 16 * -0.05, + ); + + static const TextStyle body2Regular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 16, + height: 1.38, + letterSpacing: 16 * -0.05, + ); + + static const TextStyle body3Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 15, + height: 1.38, + letterSpacing: 15 * -0.05, + ); + + static const TextStyle body3Medium = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w500, + fontSize: 15, + height: 1.38, + letterSpacing: 15 * -0.05, + ); + + static const TextStyle body3Regular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 15, + height: 1.38, + letterSpacing: 15 * -0.05, + ); + + static const TextStyle body4Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 14, + height: 1.38, + letterSpacing: 14 * -0.05, + ); + + static const TextStyle body4Medium = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w500, + fontSize: 14, + height: 1.38, + letterSpacing: 14 * -0.05, + ); + + static const TextStyle body4Regular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 14, + height: 1.38, + letterSpacing: 14 * -0.05, + ); + + static const TextStyle caption1Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w700, + fontSize: 12, + height: 1.28, + letterSpacing: 12 * -0.05, + ); + + static const TextStyle caption1Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.28, + letterSpacing: 12 * -0.05, + ); + + static const TextStyle caption1Regular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 12, + height: 1.18, + letterSpacing: 12 * -0.05, + ); + + static const TextStyle caption2Semibold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 10, + height: 1.18, + letterSpacing: 10 * -0.05, + ); + + static const TextStyle caption2Regular = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 10, + height: 1.18, + letterSpacing: 10 * -0.05, + ); +} diff --git a/lib/main.dart b/lib/main.dart index 8f4b90d..bb6a556 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,34 @@ import 'package:flutter/material.dart'; import 'package:livith/app.dart'; +import 'package:livith/providers/network_providers.dart'; +import 'package:livith/services/token_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; + +/// 개발 검증용 토큰. `--dart-define=LIVITH_DEV_TOKEN=...`로 주입하면 +/// 로그인 없이 인증 상태로 시작한다. 미주입 시 빈 값으로 일반 흐름을 따른다. +const String _devToken = String.fromEnvironment('LIVITH_DEV_TOKEN'); + +/// 카카오 네이티브 앱키. `--dart-define=KAKAO_NATIVE_APP_KEY=...`로 주입한다. +const String _kakaoNativeAppKey = String.fromEnvironment('KAKAO_NATIVE_APP_KEY'); void main() { - runApp(const ProviderScope(child: App())); + WidgetsFlutterBinding.ensureInitialized(); + if (_kakaoNativeAppKey.isNotEmpty) { + KakaoSdk.init(nativeAppKey: _kakaoNativeAppKey); + } + + runApp( + ProviderScope( + overrides: [ + if (_devToken.isNotEmpty) + tokenStoreProvider.overrideWithValue( + InMemoryTokenStore(accessToken: _devToken, refreshToken: _devToken), + ), + ], + child: const App(), + ), + ); } diff --git a/lib/models/artist.dart b/lib/models/artist.dart new file mode 100644 index 0000000..65fdf9d --- /dev/null +++ b/lib/models/artist.dart @@ -0,0 +1,25 @@ +/// 아티스트. +/// +/// iOS `PreferredArtist`/`Artist` 대응. `/search/artists` 응답에서 생성한다. +final class Artist { + const Artist({ + required this.id, + required this.name, + this.imageUrl, + this.genreId, + }); + + final int id; + final String name; + final String? imageUrl; + final int? genreId; + + factory Artist.fromJson(Map json) { + return Artist( + id: json['id'] as int, + name: json['name'] as String, + imageUrl: json['imageUrl'] as String?, + genreId: json['genreId'] as int?, + ); + } +} diff --git a/lib/models/concert.dart b/lib/models/concert.dart new file mode 100644 index 0000000..e14cb63 --- /dev/null +++ b/lib/models/concert.dart @@ -0,0 +1,61 @@ +/// 콘서트 진행 상태. +enum ConcertStatus { + ongoing('ONGOING'), + upcoming('UPCOMING'), + completed('COMPLETED'), + canceled('CANCELED'), + past('PAST'), + unknown('UNKNOWN'); + + const ConcertStatus(this.value); + + final String value; + + static ConcertStatus fromValue(String? value) { + return ConcertStatus.values.firstWhere( + (status) => status.value == value, + orElse: () => ConcertStatus.unknown, + ); + } +} + +/// 콘서트. +/// +/// iOS `Concert`/`FetchConcertInfo` 대응. +final class Concert { + const Concert({ + required this.id, + required this.title, + required this.artist, + required this.status, + this.posterUrl, + this.startDate, + this.endDate, + this.venue, + this.daysLeft, + }); + + final int id; + final String title; + final String artist; + final ConcertStatus status; + final String? posterUrl; + final String? startDate; + final String? endDate; + final String? venue; + final int? daysLeft; + + factory Concert.fromJson(Map json) { + return Concert( + id: json['id'] as int, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + status: ConcertStatus.fromValue(json['status'] as String?), + posterUrl: (json['poster'] ?? json['posterUrl']) as String?, + startDate: json['startDate'] as String?, + endDate: json['endDate'] as String?, + venue: json['venue'] as String?, + daysLeft: json['daysLeft'] as int?, + ); + } +} diff --git a/lib/models/concert_artist.dart b/lib/models/concert_artist.dart new file mode 100644 index 0000000..229ad55 --- /dev/null +++ b/lib/models/concert_artist.dart @@ -0,0 +1,22 @@ +/// 콘서트 아티스트 상세. +/// +/// iOS `Artist`(콘서트 상세) 대응. `/concerts/{id}/artist` 응답에서 생성한다. +final class ConcertArtist { + const ConcertArtist({ + required this.name, + this.imageUrl, + this.introduction, + }); + + final String name; + final String? imageUrl; + final String? introduction; + + factory ConcertArtist.fromJson(Map json) { + return ConcertArtist( + name: (json['artist'] ?? json['name']) as String? ?? '', + imageUrl: (json['imgUrl'] ?? json['imageUrl']) as String?, + introduction: (json['detail'] ?? json['introduction']) as String?, + ); + } +} diff --git a/lib/models/concert_comment.dart b/lib/models/concert_comment.dart new file mode 100644 index 0000000..54379fe --- /dev/null +++ b/lib/models/concert_comment.dart @@ -0,0 +1,28 @@ +/// 콘서트 커뮤니티 댓글. +/// +/// iOS `ConcertComment` 대응. +final class ConcertComment { + const ConcertComment({ + required this.id, + required this.writer, + required this.content, + this.createdAt, + this.userId, + }); + + final int id; + final String writer; + final String content; + final String? createdAt; + final int? userId; + + factory ConcertComment.fromJson(Map json) { + return ConcertComment( + id: json['id'] as int, + writer: json['writer'] as String? ?? '', + content: json['content'] as String? ?? '', + createdAt: json['createdAt'] as String?, + userId: json['userId'] as int?, + ); + } +} diff --git a/lib/models/genre.dart b/lib/models/genre.dart new file mode 100644 index 0000000..2feef70 --- /dev/null +++ b/lib/models/genre.dart @@ -0,0 +1,13 @@ +/// 음악 장르. +/// +/// iOS `PreferredGenre`/`ConcertGenre` 대응. `/genres` 응답에서 생성한다. +final class Genre { + const Genre({required this.id, required this.name}); + + final int id; + final String name; + + factory Genre.fromJson(Map json) { + return Genre(id: json['id'] as int, name: json['name'] as String); + } +} diff --git a/lib/models/login_status.dart b/lib/models/login_status.dart new file mode 100644 index 0000000..7581f3e --- /dev/null +++ b/lib/models/login_status.dart @@ -0,0 +1,35 @@ +import 'package:livith/models/temp_user.dart'; + +/// 소셜 로그인 결과. +/// +/// iOS `LoginStatus` 대응. 응답의 `isNewUser`로 기존/신규 사용자를 구분한다. +sealed class LoginStatus { + const LoginStatus(); + + /// 로그인 응답 JSON을 [LoginStatus]로 파싱한다. + factory LoginStatus.fromJson(Map json) { + final isNewUser = json['isNewUser'] as bool? ?? false; + if (isNewUser) { + return NewUser(TempUser.fromJson(json['tempUserData'] as Map)); + } + return ExistingUser( + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, + ); + } +} + +/// 기존 사용자. 액세스/리프레시 토큰을 보유한다. +final class ExistingUser extends LoginStatus { + const ExistingUser({required this.accessToken, required this.refreshToken}); + + final String accessToken; + final String refreshToken; +} + +/// 신규 사용자. 온보딩(가입) 진행이 필요하다. +final class NewUser extends LoginStatus { + const NewUser(this.tempUser); + + final TempUser tempUser; +} diff --git a/lib/models/nickname.dart b/lib/models/nickname.dart new file mode 100644 index 0000000..1b8a9ed --- /dev/null +++ b/lib/models/nickname.dart @@ -0,0 +1,18 @@ +/// 검증된 닉네임 값 객체. +/// +/// iOS `Nickname` 대응. 규칙: 영문/숫자/한글 1~10자(`^[a-zA-Z0-9가-힣]{1,10}$`). +final class Nickname { + const Nickname._(this.value); + + final String value; + + static final RegExp _pattern = RegExp(r'^[a-zA-Z0-9가-힣]{1,10}$'); + + /// 닉네임 규칙을 만족하는지 검사한다. + static bool isValid(String value) => _pattern.hasMatch(value); + + /// 규칙을 만족하면 [Nickname]을, 아니면 null을 반환한다. + static Nickname? tryParse(String value) { + return isValid(value) ? Nickname._(value) : null; + } +} diff --git a/lib/models/search_query.dart b/lib/models/search_query.dart new file mode 100644 index 0000000..be60db2 --- /dev/null +++ b/lib/models/search_query.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +/// 콘서트 검색 조건. +/// +/// `FutureProvider.family`의 키로 쓰이므로 값 동등성을 보장한다. +@immutable +final class SearchQuery { + const SearchQuery({this.keyword = '', this.genreNameList = const []}); + + final String keyword; + final List genreNameList; + + @override + bool operator ==(Object other) { + return other is SearchQuery && + other.keyword == keyword && + listEquals(other.genreNameList, genreNameList); + } + + @override + int get hashCode => Object.hash(keyword, Object.hashAll(genreNameList)); + + bool get isEmpty => keyword.trim().isEmpty && genreNameList.isEmpty; +} diff --git a/lib/models/setlist.dart b/lib/models/setlist.dart new file mode 100644 index 0000000..5123ac6 --- /dev/null +++ b/lib/models/setlist.dart @@ -0,0 +1,46 @@ +/// 셋리스트의 곡. +final class SetlistSong { + const SetlistSong({required this.id, required this.title, this.order}); + + final int id; + final String title; + final int? order; + + factory SetlistSong.fromJson(Map json) { + return SetlistSong( + id: json['id'] as int, + title: json['title'] as String? ?? '', + order: json['order'] as int?, + ); + } +} + +/// 셋리스트. +/// +/// iOS `Setlist` 대응. +final class Setlist { + const Setlist({ + required this.id, + required this.title, + this.artist, + this.songList = const [], + }); + + final int id; + final String title; + final String? artist; + final List songList; + + factory Setlist.fromJson(Map json) { + final songs = (json['songs'] as List?) ?? const []; + return Setlist( + id: json['id'] as int, + title: json['title'] as String? ?? '', + artist: json['artist'] as String?, + songList: songs + .cast>() + .map(SetlistSong.fromJson) + .toList(), + ); + } +} diff --git a/lib/models/signup_info.dart b/lib/models/signup_info.dart new file mode 100644 index 0000000..6571736 --- /dev/null +++ b/lib/models/signup_info.dart @@ -0,0 +1,36 @@ +import 'package:livith/models/social_provider.dart'; + +/// 회원가입 요청 정보. +/// +/// iOS `SignupInfo` 대응. `POST /auth/signup` 요청 바디로 직렬화한다. +final class SignupInfo { + const SignupInfo({ + required this.provider, + required this.providerId, + this.email, + required this.nickname, + required this.isMarketingAgreed, + required this.preferredGenreIdList, + required this.preferredArtistIdList, + }); + + final SocialProvider provider; + final String providerId; + final String? email; + final String nickname; + final bool isMarketingAgreed; + final List preferredGenreIdList; + final List preferredArtistIdList; + + Map toJson() { + return { + 'nickname': nickname, + 'preferredArtistIds': preferredArtistIdList, + 'preferredGenreIds': preferredGenreIdList, + 'email': email, + 'provider': provider.value, + 'providerId': providerId, + 'marketingConsent': isMarketingAgreed, + }; + } +} diff --git a/lib/models/social_provider.dart b/lib/models/social_provider.dart new file mode 100644 index 0000000..41a1f8d --- /dev/null +++ b/lib/models/social_provider.dart @@ -0,0 +1,18 @@ +/// 소셜 로그인 제공자. +/// +/// iOS `SocialLoginProvider` 대응. 서버 전송 값은 [value]를 사용한다. +enum SocialProvider { + apple('apple'), + kakao('kakao'); + + const SocialProvider(this.value); + + final String value; + + static SocialProvider fromValue(String value) { + return SocialProvider.values.firstWhere( + (provider) => provider.value == value, + orElse: () => SocialProvider.apple, + ); + } +} diff --git a/lib/models/song_lyrics.dart b/lib/models/song_lyrics.dart new file mode 100644 index 0000000..c6b42d7 --- /dev/null +++ b/lib/models/song_lyrics.dart @@ -0,0 +1,37 @@ +/// 곡 가사(원문/발음/번역). +/// +/// iOS `SongLyrics` 대응. +final class SongLyrics { + const SongLyrics({ + required this.id, + required this.title, + required this.artist, + this.lyricList = const [], + this.pronunciationList = const [], + this.translationList = const [], + this.youtubeId, + }); + + final int id; + final String title; + final String artist; + final List lyricList; + final List pronunciationList; + final List translationList; + final String? youtubeId; + + factory SongLyrics.fromJson(Map json) { + List stringList(String key) => + ((json[key] as List?) ?? const []).cast(); + + return SongLyrics( + id: json['id'] as int, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + lyricList: stringList('lyrics'), + pronunciationList: stringList('pronunciation'), + translationList: stringList('translation'), + youtubeId: json['youtubeId'] as String?, + ); + } +} diff --git a/lib/models/temp_user.dart b/lib/models/temp_user.dart new file mode 100644 index 0000000..a50ea46 --- /dev/null +++ b/lib/models/temp_user.dart @@ -0,0 +1,24 @@ +import 'package:livith/models/social_provider.dart'; + +/// 신규 가입 진행 중인 임시 사용자. +/// +/// iOS `TempUser` 대응. 소셜 로그인 응답의 `isNewUser=true`일 때 생성된다. +final class TempUser { + const TempUser({ + required this.provider, + required this.providerId, + this.email, + }); + + final SocialProvider provider; + final String providerId; + final String? email; + + factory TempUser.fromJson(Map json) { + return TempUser( + provider: SocialProvider.fromValue(json['provider'] as String), + providerId: json['providerId'] as String, + email: json['email'] as String?, + ); + } +} diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..f500ea1 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,49 @@ +import 'package:livith/models/social_provider.dart'; + +/// 로그인한 사용자. +/// +/// iOS `User` 대응. `GET /users/me`, signup, 닉네임 수정 응답에서 생성한다. +/// 알림 권한(`UserAuthority`) 세부는 알림 설정 마일스톤에서 확장한다. +final class User { + const User({ + required this.id, + required this.provider, + this.providerId, + this.email, + required this.nickname, + required this.hasPreferences, + required this.marketingConsent, + }); + + final int id; + final SocialProvider provider; + final String? providerId; + final String? email; + final String nickname; + final bool hasPreferences; + final bool marketingConsent; + + factory User.fromJson(Map json) { + return User( + id: json['id'] as int, + provider: SocialProvider.fromValue(json['provider'] as String), + providerId: json['providerId'] as String?, + email: json['email'] as String?, + nickname: json['nickname'] as String, + hasPreferences: (json['hasPreferredGenre'] as bool?) ?? false, + marketingConsent: (json['marketingConsent'] as bool?) ?? false, + ); + } + + User copyWith({String? nickname, bool? hasPreferences, bool? marketingConsent}) { + return User( + id: id, + provider: provider, + providerId: providerId, + email: email, + nickname: nickname ?? this.nickname, + hasPreferences: hasPreferences ?? this.hasPreferences, + marketingConsent: marketingConsent ?? this.marketingConsent, + ); + } +} diff --git a/lib/providers/concert_detail_providers.dart b/lib/providers/concert_detail_providers.dart new file mode 100644 index 0000000..8fd1377 --- /dev/null +++ b/lib/providers/concert_detail_providers.dart @@ -0,0 +1,47 @@ +import 'package:livith/models/concert.dart'; +import 'package:livith/models/concert_artist.dart'; +import 'package:livith/models/concert_comment.dart'; +import 'package:livith/models/setlist.dart'; +import 'package:livith/models/song_lyrics.dart'; +import 'package:livith/providers/service_providers.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 콘서트 상세 화면 데이터(콘서트 + 셋리스트). +final class ConcertDetail { + const ConcertDetail({required this.concert, required this.setlistList}); + + final Concert concert; + final List setlistList; +} + +/// 콘서트 상세 데이터. 셋리스트 조회 실패는 빈 목록으로 처리한다. +final concertDetailProvider = + FutureProvider.autoDispose.family((ref, concertId) async { + final concert = await ref.read(concertServiceProvider).fetchConcert(concertId); + List setlistList; + try { + setlistList = await ref.read(setlistServiceProvider).fetchSetlists(concertId); + } on Object { + setlistList = const []; + } + return ConcertDetail(concert: concert, setlistList: setlistList); +}); + +/// 곡 가사. +final songLyricsProvider = + FutureProvider.autoDispose.family((ref, songId) { + return ref.read(songServiceProvider).fetchLyrics(songId); +}); + +/// 콘서트 댓글 목록. +final concertCommentsProvider = + FutureProvider.autoDispose.family, int>((ref, concertId) { + return ref.read(commentServiceProvider).fetchComments(concertId); +}); + +/// 콘서트 아티스트 상세. +final concertArtistProvider = + FutureProvider.autoDispose.family((ref, concertId) { + return ref.read(concertServiceProvider).fetchArtist(concertId); +}); diff --git a/lib/providers/integration_providers.dart b/lib/providers/integration_providers.dart new file mode 100644 index 0000000..3bb9445 --- /dev/null +++ b/lib/providers/integration_providers.dart @@ -0,0 +1,14 @@ +import 'package:livith/services/analytics_service.dart'; +import 'package:livith/services/notification_service.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 이벤트 분석 Service. 키 확보 전까지 no-op을 사용한다. +final analyticsServiceProvider = Provider( + (ref) => const NoOpAnalyticsService(), +); + +/// 푸시 알림 Service. 네이티브 설정 전까지 stub을 사용한다. +final notificationServiceProvider = Provider( + (ref) => const StubNotificationService(), +); diff --git a/lib/providers/network_providers.dart b/lib/providers/network_providers.dart new file mode 100644 index 0000000..238941d --- /dev/null +++ b/lib/providers/network_providers.dart @@ -0,0 +1,50 @@ +import 'package:livith/services/auth_interceptor.dart'; +import 'package:livith/services/refresh_interceptor.dart'; +import 'package:livith/services/secure_token_store.dart'; +import 'package:livith/services/token_store.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// API 베이스 URL. 기본값은 개발(staging) 서버이며, +/// 배포 빌드는 `--dart-define=LIVITH_API_BASE_URL=https://api.livith.site/api/v6`로 주입한다. +const String _apiBaseUrl = String.fromEnvironment( + 'LIVITH_API_BASE_URL', + defaultValue: 'https://staging-api.livith.site/api/v6', +); + +BaseOptions _baseOptions() => BaseOptions( + baseUrl: _apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + contentType: Headers.jsonContentType, + ); + +final secureStorageProvider = Provider( + (ref) => const FlutterSecureStorage(), +); + +/// 토큰 저장소. 앱 시작 시 [SecureTokenStore.load]로 캐시를 채운다. +final tokenStoreProvider = Provider( + (ref) => SecureTokenStore(ref.read(secureStorageProvider)), +); + +/// 토큰 갱신 전용 Dio. 인터셉터가 없어 갱신 요청의 재귀를 방지한다. +final refreshDioProvider = Provider((ref) => Dio(_baseOptions())); + +/// 앱 전역에서 재사용하는 고정 설정 Dio 인스턴스. +final dioProvider = Provider((ref) { + final tokenStore = ref.read(tokenStoreProvider); + + final dio = Dio(_baseOptions()); + dio.interceptors.addAll([ + AuthInterceptor(tokenStore), + RefreshInterceptor( + refreshDio: ref.read(refreshDioProvider), + tokenStore: tokenStore, + ), + ]); + + return dio; +}); diff --git a/lib/providers/preference_providers.dart b/lib/providers/preference_providers.dart new file mode 100644 index 0000000..5b9a546 --- /dev/null +++ b/lib/providers/preference_providers.dart @@ -0,0 +1,26 @@ +import 'package:livith/models/artist.dart'; +import 'package:livith/models/concert.dart'; +import 'package:livith/models/genre.dart'; +import 'package:livith/models/search_query.dart'; +import 'package:livith/providers/service_providers.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 전체 장르 목록. +final genresProvider = FutureProvider.autoDispose>( + (ref) => ref.read(preferenceServiceProvider).fetchGenres(), +); + +/// 키워드 기반 아티스트 검색 결과. 빈 키워드는 빈 목록을 반환한다. +final artistSearchProvider = + FutureProvider.autoDispose.family, String>((ref, keyword) { + if (keyword.trim().isEmpty) return Future.value(const []); + return ref.read(preferenceServiceProvider).searchArtists(keyword: keyword); +}); + +/// 콘서트 검색 결과. 빈 조건은 빈 목록을 반환한다. +final concertSearchProvider = + FutureProvider.autoDispose.family, SearchQuery>((ref, query) { + if (query.isEmpty) return Future.value(const []); + return ref.read(searchServiceProvider).searchConcerts(query); +}); diff --git a/lib/providers/service_providers.dart b/lib/providers/service_providers.dart new file mode 100644 index 0000000..e9e1e3e --- /dev/null +++ b/lib/providers/service_providers.dart @@ -0,0 +1,63 @@ +import 'package:livith/providers/network_providers.dart'; +import 'package:livith/services/auth_service.dart'; +import 'package:livith/services/comment_service.dart'; +import 'package:livith/services/concert_service.dart'; +import 'package:livith/services/kakao_social_auth_service.dart'; +import 'package:livith/services/preference_service.dart'; +import 'package:livith/services/search_service.dart'; +import 'package:livith/services/setlist_service.dart'; +import 'package:livith/services/song_service.dart'; +import 'package:livith/services/social_auth_service.dart'; +import 'package:livith/services/user_service.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 인증/온보딩 Service. +final authServiceProvider = Provider( + (ref) => DioAuthService(ref.read(dioProvider)), +); + +/// 사용자 정보 Service. +final userServiceProvider = Provider( + (ref) => DioUserService(ref.read(dioProvider)), +); + +/// 선호 장르/아티스트 Service. +final preferenceServiceProvider = Provider( + (ref) => DioPreferenceService(ref.read(dioProvider)), +); + +/// 콘서트 조회 Service. +final concertServiceProvider = Provider( + (ref) => DioConcertService(ref.read(dioProvider)), +); + +/// 콘서트 검색 Service. +final searchServiceProvider = Provider( + (ref) => DioSearchService(ref.read(dioProvider)), +); + +/// 셋리스트 Service. +final setlistServiceProvider = Provider( + (ref) => DioSetlistService(ref.read(dioProvider)), +); + +/// 곡 가사 Service. +final songServiceProvider = Provider( + (ref) => DioSongService(ref.read(dioProvider)), +); + +/// 콘서트 댓글 Service. +final commentServiceProvider = Provider( + (ref) => DioCommentService(ref.read(dioProvider)), +); + +/// 카카오 네이티브 앱키. `--dart-define=KAKAO_NATIVE_APP_KEY=...`로 주입한다. +const String _kakaoNativeAppKey = String.fromEnvironment('KAKAO_NATIVE_APP_KEY'); + +/// 소셜 로그인 토큰 획득 Service. 카카오 키가 주입되면 실제 SDK, 아니면 stub을 사용한다. +final socialAuthServiceProvider = Provider( + (ref) => _kakaoNativeAppKey.isEmpty + ? const StubSocialAuthService() + : const KakaoSocialAuthService(), +); diff --git a/lib/providers/user_providers.dart b/lib/providers/user_providers.dart new file mode 100644 index 0000000..b843783 --- /dev/null +++ b/lib/providers/user_providers.dart @@ -0,0 +1,9 @@ +import 'package:livith/models/user.dart'; +import 'package:livith/providers/service_providers.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 현재 로그인한 사용자 프로필. +final userProfileProvider = FutureProvider.autoDispose( + (ref) => ref.read(userServiceProvider).fetchMe(), +); diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart new file mode 100644 index 0000000..3264eae --- /dev/null +++ b/lib/routes/app_router.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/routes/routes.dart'; +import 'package:livith/view_models/auth_view_model.dart'; +import 'package:livith/views/screens/design_system_preview_screen.dart'; +import 'package:livith/views/screens/concert_detail_screen.dart'; +import 'package:livith/views/screens/login_screen.dart'; +import 'package:livith/views/screens/main_tab_screen.dart'; +import 'package:livith/views/screens/nickname_edit_screen.dart'; +import 'package:livith/views/screens/notice_setting_screen.dart'; +import 'package:livith/views/screens/onboarding_screen.dart'; +import 'package:livith/views/screens/setting_screen.dart'; +import 'package:livith/views/screens/song_lyrics_screen.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 인증 상태에 따라 진입점을 분기하는 앱 라우터. +final routerProvider = Provider((ref) { + final refresh = ValueNotifier(0); + ref.onDispose(refresh.dispose); + ref.listen(authViewModelProvider, (_, _) => refresh.value++); + + return GoRouter( + initialLocation: Routes.login, + refreshListenable: refresh, + redirect: (context, state) { + final authState = ref.read(authViewModelProvider).value; + if (authState == null) return null; + + final location = state.matchedLocation; + final inOnboarding = location.startsWith(Routes.onboarding); + + return switch (authState) { + Unauthenticated() => location == Routes.login ? null : Routes.login, + OnboardingRequired() => inOnboarding ? null : Routes.onboarding, + Authenticated() => + (location == Routes.login || inOnboarding) ? Routes.home : null, + }; + }, + routes: [ + GoRoute(path: Routes.login, builder: (_, _) => const LoginScreen()), + GoRoute(path: Routes.home, builder: (_, _) => const MainTabScreen()), + GoRoute( + path: Routes.onboarding, + builder: (_, _) => const OnboardingScreen(), + ), + GoRoute( + path: '/concert/:id', + builder: (_, state) => ConcertDetailScreen( + concertId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: '/song/:id', + builder: (_, state) => SongLyricsScreen( + songId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute(path: '/setting', builder: (_, _) => const SettingScreen()), + GoRoute(path: '/notice-setting', builder: (_, _) => const NoticeSettingScreen()), + GoRoute(path: '/nickname-edit', builder: (_, _) => const NicknameEditScreen()), + GoRoute( + path: Routes.designSystemPreview, + builder: (_, _) => const DesignSystemPreviewScreen(), + ), + ], + ); +}); diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 7d2815f..f892c03 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -2,4 +2,7 @@ class Routes { const Routes._(); static const String home = '/'; + static const String login = '/login'; + static const String onboarding = '/onboarding'; + static const String designSystemPreview = '/design-system'; } diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart new file mode 100644 index 0000000..a6fd1d7 --- /dev/null +++ b/lib/services/analytics_service.dart @@ -0,0 +1,15 @@ +/// 이벤트 분석 인터페이스. +/// +/// iOS `AmplitudeService` 대응. 실제 SDK 연동은 키 확보 후 구현으로 교체한다. +abstract interface class AnalyticsService { + /// 이벤트를 기록한다. + void track(String event, [Map properties]); +} + +/// 분석 비활성(개발/키 미확보) 시 사용하는 no-op 구현. +final class NoOpAnalyticsService implements AnalyticsService { + const NoOpAnalyticsService(); + + @override + void track(String event, [Map properties = const {}]) {} +} diff --git a/lib/services/api_response.dart b/lib/services/api_response.dart new file mode 100644 index 0000000..dadb42f --- /dev/null +++ b/lib/services/api_response.dart @@ -0,0 +1,30 @@ +/// 서버 공통 응답 래퍼. +/// +/// iOS `BaseResponse` 대응. JSON 키: `statusCode`, `error`, `message`, `data`. +final class ApiResponse { + const ApiResponse({ + required this.statusCode, + this.error, + required this.message, + this.data, + }); + + final int statusCode; + final String? error; + final String message; + final T? data; + + /// `data`는 [dataParser]로 변환한다. `data`가 null이면 변환하지 않는다. + factory ApiResponse.fromJson( + Map json, + T Function(Object? data) dataParser, + ) { + final data = json['data']; + return ApiResponse( + statusCode: json['statusCode'] as int, + error: json['error'] as String?, + message: json['message'] as String? ?? '', + data: data == null ? null : dataParser(data), + ); + } +} diff --git a/lib/services/auth_interceptor.dart b/lib/services/auth_interceptor.dart new file mode 100644 index 0000000..2edced0 --- /dev/null +++ b/lib/services/auth_interceptor.dart @@ -0,0 +1,19 @@ +import 'package:livith/services/token_store.dart'; + +import 'package:dio/dio.dart'; + +/// 모든 요청에 인증 토큰을 부착하는 Dio 인터셉터. +final class AuthInterceptor extends Interceptor { + AuthInterceptor(this._tokenStore); + + final TokenStore _tokenStore; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = _tokenStore.accessToken; + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..0974210 --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,136 @@ +import 'package:livith/models/login_status.dart'; +import 'package:livith/models/signup_info.dart'; +import 'package:livith/models/user.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// 인증/온보딩 API 인터페이스. +/// +/// iOS `AuthRepository`/`OnboardingEndpoint` 대응. 실패는 도메인 `Failure`로 던진다. +abstract interface class AuthService { + /// 애플 로그인. `identityToken`을 전달하고 로그인 상태를 반환한다. + Future loginWithApple(String identityToken); + + /// 카카오 로그인. 카카오 `accessToken`을 전달하고 로그인 상태를 반환한다. + Future loginWithKakao(String accessToken); + + /// 회원가입. 성공 시 토큰과 사용자 정보를 반환한다. + Future signup(SignupInfo info); + + /// 닉네임 사용 가능 여부. `available`이 true면 사용 가능하다. + Future isNicknameAvailable(String nickname); + + /// 로그아웃. + Future logout(String refreshToken); + + /// 회원 탈퇴. + Future withdraw(String reason); +} + +/// Dio 기반 [AuthService] 구현. +final class DioAuthService implements AuthService { + DioAuthService(this._dio); + + final Dio _dio; + + @override + Future loginWithApple(String identityToken) { + return _login('/auth/apple/mobile', {'identityToken': identityToken}); + } + + @override + Future loginWithKakao(String accessToken) { + return _login('/auth/kakao/mobile', {'accessToken': accessToken}); + } + + @override + Future signup(SignupInfo info) async { + try { + final response = await _dio.post>( + '/auth/signup', + queryParameters: {'client': 'mobile'}, + data: info.toJson(), + ); + final data = _data(response); + return SignupResult( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String, + user: User.fromJson(data['user'] as Map), + ); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future isNicknameAvailable(String nickname) async { + try { + final response = await _dio.get>( + '/users/check-nickname', + queryParameters: {'nickname': nickname}, + ); + return _data(response)['available'] as bool; + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future logout(String refreshToken) async { + try { + await _dio.post>( + '/auth/logout', + data: {'refreshToken': refreshToken}, + ); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future withdraw(String reason) async { + try { + await _dio.post>( + '/auth/withdraw', + data: {'reason': reason}, + ); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + Future _login(String path, Map body) async { + try { + final response = await _dio.post>(path, data: body); + return LoginStatus.fromJson(_data(response)); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + Map _data(Response> response) { + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return data; + } +} + +/// 회원가입 성공 결과. +final class SignupResult { + const SignupResult({ + required this.accessToken, + required this.refreshToken, + required this.user, + }); + + final String accessToken; + final String refreshToken; + final User user; +} diff --git a/lib/services/comment_service.dart b/lib/services/comment_service.dart new file mode 100644 index 0000000..1322ff4 --- /dev/null +++ b/lib/services/comment_service.dart @@ -0,0 +1,67 @@ +import 'package:livith/models/concert_comment.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; + +import 'package:dio/dio.dart'; + +/// 콘서트 커뮤니티 댓글 인터페이스. +/// +/// iOS `CommentRepository` 대응. +abstract interface class CommentService { + /// 콘서트 댓글 목록. + Future> fetchComments(int concertId); + + /// 댓글 작성. + Future createComment(int concertId, String content); + + /// 댓글 삭제. + Future deleteComment(int commentId); +} + +/// Dio 기반 [CommentService] 구현. +final class DioCommentService implements CommentService { + DioCommentService(this._dio); + + final Dio _dio; + + @override + Future> fetchComments(int concertId) async { + try { + final response = await _dio.get>( + '/concerts/$concertId/comments', + ); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final list = (parsed.data?['data'] as List?) ?? const []; + return list + .cast>() + .map(ConcertComment.fromJson) + .toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future createComment(int concertId, String content) async { + try { + await _dio.post>( + '/concerts/$concertId/comments', + data: {'content': content}, + ); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future deleteComment(int commentId) async { + try { + await _dio.delete>('/comments/$commentId'); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } +} diff --git a/lib/services/concert_service.dart b/lib/services/concert_service.dart new file mode 100644 index 0000000..9bb6de8 --- /dev/null +++ b/lib/services/concert_service.dart @@ -0,0 +1,92 @@ +import 'package:livith/models/concert.dart'; +import 'package:livith/models/concert_artist.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// 콘서트 조회 인터페이스. +/// +/// iOS `ConcertRepository`/`HomeRepository` 일부 대응. +abstract interface class ConcertService { + /// 추천 콘서트 목록. + Future> fetchRecommendedConcerts(); + + /// 사용자의 관심 콘서트 목록. + Future> fetchInterestConcerts(); + + /// 콘서트 단건 상세. + Future fetchConcert(int id); + + /// 콘서트 아티스트 상세. + Future fetchArtist(int concertId); +} + +/// Dio 기반 [ConcertService] 구현. +final class DioConcertService implements ConcertService { + DioConcertService(this._dio); + + final Dio _dio; + + @override + Future> fetchRecommendedConcerts() { + return _concertList('/recommendation/concerts'); + } + + @override + Future> fetchInterestConcerts() { + return _concertList('/users/interest-concerts'); + } + + @override + Future fetchConcert(int id) async { + try { + final response = await _dio.get>('/concerts/$id'); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return Concert.fromJson(data); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future fetchArtist(int concertId) async { + try { + final response = await _dio.get>( + '/concerts/$concertId/artist', + ); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return ConcertArtist.fromJson(data); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + Future> _concertList(String path) async { + try { + final response = await _dio.get>(path); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as List, + ); + final data = parsed.data ?? const []; + return data + .cast>() + .map(Concert.fromJson) + .toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } +} diff --git a/lib/services/dio_failure_mapper.dart b/lib/services/dio_failure_mapper.dart new file mode 100644 index 0000000..88375d2 --- /dev/null +++ b/lib/services/dio_failure_mapper.dart @@ -0,0 +1,34 @@ +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// `DioException`을 도메인 [Failure]로 매핑한다. +Failure mapDioException(DioException exception) { + switch (exception.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return const NetworkFailure(); + case DioExceptionType.badResponse: + final statusCode = exception.response?.statusCode; + if (statusCode == 401) return const AuthFailure(); + return ServerFailure( + _serverMessage(exception) ?? '서버 오류가 발생했어요', + statusCode: statusCode, + ); + case DioExceptionType.cancel: + case DioExceptionType.badCertificate: + case DioExceptionType.unknown: + return const UnknownFailure(); + } +} + +String? _serverMessage(DioException exception) { + final data = exception.response?.data; + if (data is Map) { + final message = data['message'] ?? data['error']; + if (message is String && message.isNotEmpty) return message; + } + return null; +} diff --git a/lib/services/failure.dart b/lib/services/failure.dart new file mode 100644 index 0000000..f3b04df --- /dev/null +++ b/lib/services/failure.dart @@ -0,0 +1,35 @@ +/// 도메인 계층에서 사용하는 실패 표현. +/// +/// `Service`/`Repository`는 외부(네트워크/저장소) 오류를 이 타입으로 매핑한다. +sealed class Failure implements Exception { + const Failure(this.message); + + final String message; +} + +/// 네트워크 연결/타임아웃 실패. +final class NetworkFailure extends Failure { + const NetworkFailure([super.message = '네트워크 연결을 확인해주세요']); +} + +/// 인증 실패(401). 토큰 갱신 불가 시 로그아웃 처리에 사용한다. +final class AuthFailure extends Failure { + const AuthFailure([super.message = '인증이 필요합니다']); +} + +/// 서버 응답 오류(4xx/5xx). +final class ServerFailure extends Failure { + const ServerFailure(super.message, {this.statusCode}); + + final int? statusCode; +} + +/// 응답 파싱 실패. +final class ParsingFailure extends Failure { + const ParsingFailure([super.message = '응답을 해석할 수 없습니다']); +} + +/// 분류되지 않은 실패. +final class UnknownFailure extends Failure { + const UnknownFailure([super.message = '알 수 없는 오류가 발생했습니다']); +} diff --git a/lib/services/kakao_social_auth_service.dart b/lib/services/kakao_social_auth_service.dart new file mode 100644 index 0000000..da650eb --- /dev/null +++ b/lib/services/kakao_social_auth_service.dart @@ -0,0 +1,24 @@ +import 'package:livith/services/social_auth_service.dart'; + +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; + +/// 카카오 SDK 기반 소셜 로그인 구현. +/// +/// 카카오톡이 설치돼 있으면 톡으로, 아니면 카카오 계정으로 로그인한다. +/// 애플 로그인은 Android에서 지원하지 않는다. +final class KakaoSocialAuthService implements SocialAuthService { + const KakaoSocialAuthService(); + + @override + Future obtainKakaoAccessToken() async { + final token = await isKakaoTalkInstalled() + ? await UserApi.instance.loginWithKakaoTalk() + : await UserApi.instance.loginWithKakaoAccount(); + return token.accessToken; + } + + @override + Future obtainAppleIdentityToken() { + throw UnsupportedError('Android에서는 애플 로그인을 지원하지 않습니다'); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..66938ff --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,27 @@ +/// 푸시 알림 토큰 관리 인터페이스. +/// +/// iOS FCM 연동 대응. 실제 SDK(Firebase Messaging) 연동은 네이티브 설정 후 교체한다. +abstract interface class NotificationService { + /// FCM 토큰을 서버에 등록한다. + Future registerToken(); + + /// FCM 토큰 등록을 해제한다. + Future unregisterToken(); + + /// 마케팅 정보 수신 동의를 갱신한다. + Future updateMarketingConsent({required bool agreed}); +} + +/// 키/네이티브 설정 전 사용하는 stub 구현. +final class StubNotificationService implements NotificationService { + const StubNotificationService(); + + @override + Future registerToken() async {} + + @override + Future unregisterToken() async {} + + @override + Future updateMarketingConsent({required bool agreed}) async {} +} diff --git a/lib/services/preference_service.dart b/lib/services/preference_service.dart new file mode 100644 index 0000000..42ae11c --- /dev/null +++ b/lib/services/preference_service.dart @@ -0,0 +1,58 @@ +import 'package:livith/models/artist.dart'; +import 'package:livith/models/genre.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// 선호 장르/아티스트 조회 인터페이스. +/// +/// iOS `PreferenceRepository`/`SearchRepository` 일부 대응. +abstract interface class PreferenceService { + /// 전체 장르 목록을 조회한다. + Future> fetchGenres(); + + /// 키워드로 아티스트를 검색한다. + Future> searchArtists({required String keyword, int size}); +} + +/// Dio 기반 [PreferenceService] 구현. +final class DioPreferenceService implements PreferenceService { + DioPreferenceService(this._dio); + + final Dio _dio; + + @override + Future> fetchGenres() async { + try { + final response = await _dio.get>('/genres'); + return _list(response).map(Genre.fromJson).toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future> searchArtists({required String keyword, int size = 20}) async { + try { + final response = await _dio.get>( + '/search/artists', + queryParameters: {'keyword': keyword, 'size': size}, + ); + return _list(response).map(Artist.fromJson).toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + List> _list(Response> response) { + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as List, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return data.cast>(); + } +} diff --git a/lib/services/refresh_interceptor.dart b/lib/services/refresh_interceptor.dart new file mode 100644 index 0000000..47ec8df --- /dev/null +++ b/lib/services/refresh_interceptor.dart @@ -0,0 +1,62 @@ +import 'package:livith/services/token_refresh_policy.dart'; +import 'package:livith/services/token_store.dart'; + +import 'package:dio/dio.dart'; + +/// 401 응답 시 토큰을 갱신하고 원 요청을 1회 재시도하는 인터셉터. +/// +/// 갱신 요청은 인터셉터가 없는 [refreshDio]로 보내 재귀를 방지하고, +/// 갱신에 실패하면 토큰을 비워 로그아웃 상태로 만든다. +final class RefreshInterceptor extends Interceptor { + RefreshInterceptor({required this.refreshDio, required this.tokenStore}); + + final Dio refreshDio; + final TokenStore tokenStore; + + static const String _retriedKey = 'retried'; + + @override + Future onError(DioException err, ErrorInterceptorHandler handler) async { + final alreadyRetried = err.requestOptions.extra[_retriedKey] == true; + final canRefresh = shouldAttemptRefresh( + statusCode: err.response?.statusCode, + hasRefreshToken: tokenStore.refreshToken != null, + alreadyRetried: alreadyRetried, + ); + if (!canRefresh) { + handler.next(err); + return; + } + + try { + await _refreshTokens(); + final response = await _retry(err.requestOptions); + handler.resolve(response); + } on DioException catch (_) { + await tokenStore.clear(); + handler.next(err); + } + } + + Future _refreshTokens() async { + final response = await refreshDio.post>( + '/auth/refresh', + queryParameters: {'client': 'mobile'}, + data: {'refreshToken': tokenStore.refreshToken}, + ); + final data = response.data?['data'] as Map?; + if (data == null) { + throw DioException(requestOptions: response.requestOptions); + } + await tokenStore.save( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String, + ); + } + + Future> _retry(RequestOptions options) { + options.extra[_retriedKey] = true; + options.headers['Authorization'] = 'Bearer ${tokenStore.accessToken}'; + return refreshDio.fetch(options); + } +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart new file mode 100644 index 0000000..778db8b --- /dev/null +++ b/lib/services/search_service.dart @@ -0,0 +1,43 @@ +import 'package:livith/models/concert.dart'; +import 'package:livith/models/search_query.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; + +import 'package:dio/dio.dart'; + +/// 콘서트 검색 인터페이스. +/// +/// iOS `SearchRepository` 일부 대응. +abstract interface class SearchService { + /// 키워드/장르 조건으로 콘서트를 검색한다. + Future> searchConcerts(SearchQuery query); +} + +/// Dio 기반 [SearchService] 구현. +final class DioSearchService implements SearchService { + DioSearchService(this._dio); + + final Dio _dio; + + @override + Future> searchConcerts(SearchQuery query) async { + try { + final response = await _dio.get>( + '/search/concerts', + queryParameters: { + if (query.keyword.trim().isNotEmpty) 'keyword': query.keyword.trim(), + if (query.genreNameList.isNotEmpty) 'genre': query.genreNameList, + 'size': 30, + }, + ); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final list = (parsed.data?['data'] as List?) ?? const []; + return list.cast>().map(Concert.fromJson).toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } +} diff --git a/lib/services/secure_token_store.dart b/lib/services/secure_token_store.dart new file mode 100644 index 0000000..0c1e5de --- /dev/null +++ b/lib/services/secure_token_store.dart @@ -0,0 +1,51 @@ +import 'package:livith/services/token_store.dart'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// `flutter_secure_storage`(iOS Keychain/Android Keystore) 기반 토큰 저장소. +/// +/// `AuthInterceptor`가 동기로 토큰을 읽을 수 있도록 메모리 캐시를 유지하고, +/// 앱 시작 시 [load]로 저장소 값을 적재한다. +final class SecureTokenStore implements TokenStore { + SecureTokenStore(this._storage); + + final FlutterSecureStorage _storage; + + static const String _accessKey = 'livith.accessToken'; + static const String _refreshKey = 'livith.refreshToken'; + + String? _accessToken; + String? _refreshToken; + + @override + String? get accessToken => _accessToken; + + @override + String? get refreshToken => _refreshToken; + + /// 저장소의 토큰을 메모리 캐시로 적재한다. 앱 시작 시 1회 호출한다. + @override + Future load() async { + _accessToken = await _storage.read(key: _accessKey); + _refreshToken = await _storage.read(key: _refreshKey); + } + + @override + Future save({ + required String accessToken, + required String refreshToken, + }) async { + _accessToken = accessToken; + _refreshToken = refreshToken; + await _storage.write(key: _accessKey, value: accessToken); + await _storage.write(key: _refreshKey, value: refreshToken); + } + + @override + Future clear() async { + _accessToken = null; + _refreshToken = null; + await _storage.delete(key: _accessKey); + await _storage.delete(key: _refreshKey); + } +} diff --git a/lib/services/setlist_service.dart b/lib/services/setlist_service.dart new file mode 100644 index 0000000..e3ddc4e --- /dev/null +++ b/lib/services/setlist_service.dart @@ -0,0 +1,37 @@ +import 'package:livith/models/setlist.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; + +import 'package:dio/dio.dart'; + +/// 셋리스트 조회 인터페이스. +/// +/// iOS `SetlistRepository` 대응. +abstract interface class SetlistService { + /// 콘서트의 셋리스트 목록. + Future> fetchSetlists(int concertId); +} + +/// Dio 기반 [SetlistService] 구현. +final class DioSetlistService implements SetlistService { + DioSetlistService(this._dio); + + final Dio _dio; + + @override + Future> fetchSetlists(int concertId) async { + try { + final response = await _dio.get>( + '/concerts/$concertId/setlists', + ); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as List, + ); + final data = parsed.data ?? const []; + return data.cast>().map(Setlist.fromJson).toList(); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } +} diff --git a/lib/services/social_auth_service.dart b/lib/services/social_auth_service.dart new file mode 100644 index 0000000..ca8f6b3 --- /dev/null +++ b/lib/services/social_auth_service.dart @@ -0,0 +1,34 @@ +import 'package:livith/models/social_provider.dart'; + +/// 소셜 로그인 토큰 획득 인터페이스. +/// +/// iOS `SocialAuthService`(Kakao/Apple SDK) 대응. 실제 SDK 연동은 네이티브 키 설정이 +/// 필요하므로, 키 확보 전까지 [StubSocialAuthService]를 사용한다. +abstract interface class SocialAuthService { + /// 애플 `identityToken`을 획득한다. + Future obtainAppleIdentityToken(); + + /// 카카오 `accessToken`을 획득한다. + Future obtainKakaoAccessToken(); +} + +/// 키 확보 전 사용하는 stub 구현. 고정 토큰을 반환한다. +final class StubSocialAuthService implements SocialAuthService { + const StubSocialAuthService(); + + @override + Future obtainAppleIdentityToken() async => 'stub-apple-identity-token'; + + @override + Future obtainKakaoAccessToken() async => 'stub-kakao-access-token'; +} + +/// 소셜 로그인 제공자별 토큰 획득을 단일 메서드로 노출한다. +extension SocialAuthByProvider on SocialAuthService { + Future obtainToken(SocialProvider provider) { + return switch (provider) { + SocialProvider.apple => obtainAppleIdentityToken(), + SocialProvider.kakao => obtainKakaoAccessToken(), + }; + } +} diff --git a/lib/services/song_service.dart b/lib/services/song_service.dart new file mode 100644 index 0000000..3cf927e --- /dev/null +++ b/lib/services/song_service.dart @@ -0,0 +1,37 @@ +import 'package:livith/models/song_lyrics.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// 곡 가사 조회 인터페이스. +/// +/// iOS `SongRepository` 대응. +abstract interface class SongService { + /// 곡 가사를 조회한다. + Future fetchLyrics(int songId); +} + +/// Dio 기반 [SongService] 구현. +final class DioSongService implements SongService { + DioSongService(this._dio); + + final Dio _dio; + + @override + Future fetchLyrics(int songId) async { + try { + final response = await _dio.get>('/songs/$songId'); + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return SongLyrics.fromJson(data); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } +} diff --git a/lib/services/token_refresh_policy.dart b/lib/services/token_refresh_policy.dart new file mode 100644 index 0000000..865e580 --- /dev/null +++ b/lib/services/token_refresh_policy.dart @@ -0,0 +1,13 @@ +/// 401 응답에 대해 토큰 갱신을 시도할지 판단한다. +/// +/// 무한 재요청을 막기 위해 이미 재시도한 요청은 제외하고, +/// 리프레시 토큰이 있을 때만 갱신을 시도한다. +bool shouldAttemptRefresh({ + required int? statusCode, + required bool hasRefreshToken, + required bool alreadyRetried, +}) { + if (statusCode != 401) return false; + if (alreadyRetried) return false; + return hasRefreshToken; +} diff --git a/lib/services/token_store.dart b/lib/services/token_store.dart new file mode 100644 index 0000000..6c8dca0 --- /dev/null +++ b/lib/services/token_store.dart @@ -0,0 +1,47 @@ +/// 액세스/리프레시 토큰을 보관하는 저장소. +/// +/// 마일스톤 1에서는 인메모리 구현만 제공하고, 영속 저장(Keychain/SecureStorage)은 +/// 인증 마일스톤에서 별도 구현으로 교체한다. +abstract interface class TokenStore { + String? get accessToken; + String? get refreshToken; + + /// 영속 저장소의 토큰을 메모리 캐시로 적재한다(인메모리 구현은 no-op). + Future load(); + Future save({required String accessToken, required String refreshToken}); + Future clear(); +} + +final class InMemoryTokenStore implements TokenStore { + InMemoryTokenStore({String? accessToken, String? refreshToken}) { + _accessToken = accessToken; + _refreshToken = refreshToken; + } + + String? _accessToken; + String? _refreshToken; + + @override + String? get accessToken => _accessToken; + + @override + String? get refreshToken => _refreshToken; + + @override + Future load() async {} + + @override + Future save({ + required String accessToken, + required String refreshToken, + }) async { + _accessToken = accessToken; + _refreshToken = refreshToken; + } + + @override + Future clear() async { + _accessToken = null; + _refreshToken = null; + } +} diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart new file mode 100644 index 0000000..d27b2e7 --- /dev/null +++ b/lib/services/user_service.dart @@ -0,0 +1,57 @@ +import 'package:livith/models/user.dart'; +import 'package:livith/services/api_response.dart'; +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +/// 사용자 정보 API 인터페이스. +/// +/// iOS `UserRepository`/`UserEndpoint` 대응. 인증이 필요한 요청만 다룬다. +abstract interface class UserService { + /// 현재 로그인한 사용자 정보를 조회한다. + Future fetchMe(); + + /// 닉네임을 수정하고 갱신된 사용자 정보를 반환한다. + Future updateNickname(String nickname); +} + +/// Dio 기반 [UserService] 구현. +final class DioUserService implements UserService { + DioUserService(this._dio); + + final Dio _dio; + + @override + Future fetchMe() async { + try { + final response = await _dio.get>('/users/me'); + return User.fromJson(_data(response)); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + @override + Future updateNickname(String nickname) async { + try { + final response = await _dio.patch>( + '/users/nickname', + data: {'nickname': nickname}, + ); + return User.fromJson(_data(response)); + } on DioException catch (exception) { + throw mapDioException(exception); + } + } + + Map _data(Response> response) { + final parsed = ApiResponse>.fromJson( + response.data ?? const {}, + (data) => data as Map, + ); + final data = parsed.data; + if (data == null) throw const ParsingFailure(); + return data; + } +} diff --git a/lib/view_models/auth_view_model.dart b/lib/view_models/auth_view_model.dart new file mode 100644 index 0000000..576d215 --- /dev/null +++ b/lib/view_models/auth_view_model.dart @@ -0,0 +1,99 @@ +import 'package:livith/models/login_status.dart'; +import 'package:livith/models/social_provider.dart'; +import 'package:livith/models/temp_user.dart'; +import 'package:livith/providers/network_providers.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/services/auth_service.dart'; +import 'package:livith/services/social_auth_service.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱 전역 인증 상태. +sealed class AuthState { + const AuthState(); +} + +/// 로그인 완료 상태. +final class Authenticated extends AuthState { + const Authenticated(); +} + +/// 미로그인 상태. +final class Unauthenticated extends AuthState { + const Unauthenticated(); +} + +/// 신규 사용자라 온보딩(가입)이 필요한 상태. +final class OnboardingRequired extends AuthState { + const OnboardingRequired(this.tempUser); + + final TempUser tempUser; +} + +/// 인증 상태를 관리하는 ViewModel. +/// +/// 앱 시작 시 토큰을 로드해 로그인 여부를 판정하고, 소셜 로그인/가입/로그아웃을 처리한다. +final class AuthViewModel extends AsyncNotifier { + @override + Future build() async { + final tokenStore = ref.read(tokenStoreProvider); + await tokenStore.load(); + return tokenStore.accessToken != null + ? const Authenticated() + : const Unauthenticated(); + } + + /// 소셜 로그인을 수행하고 결과에 따라 상태를 갱신한다. + Future loginWith(SocialProvider provider) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final token = await ref.read(socialAuthServiceProvider).obtainToken(provider); + final auth = ref.read(authServiceProvider); + final status = provider == SocialProvider.apple + ? await auth.loginWithApple(token) + : await auth.loginWithKakao(token); + return _resolve(status); + }); + } + + /// 가입 완료 후 토큰을 저장하고 인증 상태로 전환한다. + Future completeOnboarding(SignupResult result) async { + await ref.read(tokenStoreProvider).save( + accessToken: result.accessToken, + refreshToken: result.refreshToken, + ); + state = const AsyncData(Authenticated()); + } + + /// 로그아웃하고 미인증 상태로 전환한다. + Future logout() async { + final tokenStore = ref.read(tokenStoreProvider); + final refreshToken = tokenStore.refreshToken; + if (refreshToken != null) { + try { + await ref.read(authServiceProvider).logout(refreshToken); + } on Object { + // 서버 로그아웃 실패와 무관하게 로컬 토큰은 비운다. + } + } + await tokenStore.clear(); + state = const AsyncData(Unauthenticated()); + } + + Future _resolve(LoginStatus status) async { + switch (status) { + case ExistingUser(:final accessToken, :final refreshToken): + await ref.read(tokenStoreProvider).save( + accessToken: accessToken, + refreshToken: refreshToken, + ); + return const Authenticated(); + case NewUser(:final tempUser): + return OnboardingRequired(tempUser); + } + } +} + +final authViewModelProvider = AsyncNotifierProvider( + AuthViewModel.new, +); diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart new file mode 100644 index 0000000..e26009e --- /dev/null +++ b/lib/view_models/home_view_model.dart @@ -0,0 +1,40 @@ +import 'package:livith/models/concert.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/services/concert_service.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 홈 화면 상태. +final class HomeState { + const HomeState({this.recommendedConcertList = const [], this.interestConcertList = const []}); + + final List recommendedConcertList; + final List interestConcertList; +} + +/// 홈 화면 ViewModel. 추천/관심 콘서트를 로드한다. +final class HomeViewModel extends AsyncNotifier { + @override + Future build() async { + final service = ref.read(concertServiceProvider); + final recommended = await service.fetchRecommendedConcerts(); + final interest = await _interestOrEmpty(service); + return HomeState( + recommendedConcertList: recommended, + interestConcertList: interest, + ); + } + + /// 관심 콘서트는 미인증/실패 시 빈 목록으로 처리한다. + Future> _interestOrEmpty(ConcertService service) async { + try { + return await service.fetchInterestConcerts(); + } on Object { + return const []; + } + } +} + +final homeViewModelProvider = AsyncNotifierProvider( + HomeViewModel.new, +); diff --git a/lib/view_models/onboarding_view_model.dart b/lib/view_models/onboarding_view_model.dart new file mode 100644 index 0000000..7b1e13c --- /dev/null +++ b/lib/view_models/onboarding_view_model.dart @@ -0,0 +1,99 @@ +import 'package:livith/models/signup_info.dart'; +import 'package:livith/models/temp_user.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/view_models/auth_view_model.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 온보딩(가입) 진행 중 누적되는 상태. +final class OnboardingState { + const OnboardingState({ + this.tempUser, + this.agreedTerms = false, + this.nickname = '', + this.marketingAgreed = false, + this.genreIdList = const [], + this.artistIdList = const [], + }); + + final TempUser? tempUser; + final bool agreedTerms; + final String nickname; + final bool marketingAgreed; + final List genreIdList; + final List artistIdList; + + OnboardingState copyWith({ + TempUser? tempUser, + bool? agreedTerms, + String? nickname, + bool? marketingAgreed, + List? genreIdList, + List? artistIdList, + }) { + return OnboardingState( + tempUser: tempUser ?? this.tempUser, + agreedTerms: agreedTerms ?? this.agreedTerms, + nickname: nickname ?? this.nickname, + marketingAgreed: marketingAgreed ?? this.marketingAgreed, + genreIdList: genreIdList ?? this.genreIdList, + artistIdList: artistIdList ?? this.artistIdList, + ); + } +} + +/// 온보딩 흐름의 입력을 누적하고 최종 가입을 수행하는 ViewModel. +final class OnboardingViewModel extends Notifier { + @override + OnboardingState build() => const OnboardingState(); + + /// 로그인에서 받은 임시 사용자로 온보딩을 초기화한다. + void initialize(TempUser tempUser) { + state = OnboardingState(tempUser: tempUser); + } + + void setAgreedTerms({required bool agreed, required bool marketing}) { + state = state.copyWith(agreedTerms: agreed, marketingAgreed: marketing); + } + + void setNickname(String nickname) { + state = state.copyWith(nickname: nickname); + } + + /// 장르 선택을 토글한다. + void toggleGenre(int id) { + state = state.copyWith(genreIdList: _toggled(state.genreIdList, id)); + } + + /// 아티스트 선택을 토글한다. + void toggleArtist(int id) { + state = state.copyWith(artistIdList: _toggled(state.artistIdList, id)); + } + + /// 누적된 정보로 회원가입을 수행하고 인증 상태로 전환한다. + Future submit() async { + final tempUser = state.tempUser; + if (tempUser == null) return; + + final info = SignupInfo( + provider: tempUser.provider, + providerId: tempUser.providerId, + email: tempUser.email, + nickname: state.nickname, + isMarketingAgreed: state.marketingAgreed, + preferredGenreIdList: state.genreIdList, + preferredArtistIdList: state.artistIdList, + ); + final result = await ref.read(authServiceProvider).signup(info); + await ref.read(authViewModelProvider.notifier).completeOnboarding(result); + } + + List _toggled(List list, int id) { + return list.contains(id) + ? list.where((value) => value != id).toList() + : [...list, id]; + } +} + +final onboardingViewModelProvider = + NotifierProvider(OnboardingViewModel.new); diff --git a/lib/views/screens/concert_detail_screen.dart b/lib/views/screens/concert_detail_screen.dart new file mode 100644 index 0000000..d26b6e3 --- /dev/null +++ b/lib/views/screens/concert_detail_screen.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/concert.dart'; +import 'package:livith/models/setlist.dart'; +import 'package:livith/providers/concert_detail_providers.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/views/widgets/async_image_view.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; +import 'package:livith/views/widgets/segmented_tab_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 콘서트 상세 화면. +/// +/// iOS `ConcertView` 대응. 정보/셋리스트 탭을 표시한다(아티스트/커뮤니티 탭은 후속). +class ConcertDetailScreen extends ConsumerStatefulWidget { + const ConcertDetailScreen({super.key, required this.concertId}); + + final int concertId; + + @override + ConsumerState createState() => _ConcertDetailScreenState(); +} + +class _ConcertDetailScreenState extends ConsumerState { + int _tab = 0; + + @override + Widget build(BuildContext context) { + final detailAsync = ref.watch(concertDetailProvider(widget.concertId)); + + return Scaffold( + appBar: LivithNavigationBar.backOnly(onBack: () => context.pop()), + body: detailAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('공연 정보를 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (detail) => Column( + children: [ + SegmentedTabBar( + tabs: const ['아티스트', '정보', '셋리스트', '커뮤니티'], + selectedIndex: _tab, + onTabSelected: (index) => setState(() => _tab = index), + isScrollable: true, + tabWidth: 96, + ), + Expanded( + child: switch (_tab) { + 0 => _ArtistTab(concertId: widget.concertId), + 1 => _InfoTab(concert: detail.concert), + 2 => _SetlistTab(setlistList: detail.setlistList), + _ => _CommunityTab(concertId: widget.concertId), + }, + ), + ], + ), + ), + ); + } +} + +class _ArtistTab extends ConsumerWidget { + const _ArtistTab({required this.concertId}); + + final int concertId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final artistAsync = ref.watch(concertArtistProvider(concertId)); + + return artistAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('아티스트 정보를 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (artist) => ListView( + padding: const EdgeInsets.all(20), + children: [ + if (artist.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox(height: 200, child: AsyncImageView(url: artist.imageUrl)), + ), + const SizedBox(height: 16), + Text( + artist.name, + style: LivithTextStyles.headSemibold.copyWith(color: LivithColors.white100), + ), + if (artist.introduction != null) ...[ + const SizedBox(height: 12), + Text( + artist.introduction!, + style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.black30), + ), + ], + ], + ), + ); + } +} + +class _InfoTab extends StatelessWidget { + const _InfoTab({required this.concert}); + + final Concert concert; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(20), + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 240, + child: AsyncImageView(url: concert.posterUrl), + ), + ), + const SizedBox(height: 16), + Text( + concert.title, + style: LivithTextStyles.headSemibold.copyWith(color: LivithColors.white100), + ), + const SizedBox(height: 8), + Text( + concert.artist, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.black50), + ), + if (concert.venue != null) ...[ + const SizedBox(height: 16), + _InfoRow(label: '장소', value: concert.venue!), + ], + if (concert.startDate != null) + _InfoRow(label: '일정', value: concert.startDate!), + ], + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 48, + child: Text(label, style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.black50)), + ), + Expanded( + child: Text(value, style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.white100)), + ), + ], + ), + ); + } +} + +class _CommunityTab extends ConsumerStatefulWidget { + const _CommunityTab({required this.concertId}); + + final int concertId; + + @override + ConsumerState<_CommunityTab> createState() => _CommunityTabState(); +} + +class _CommunityTabState extends ConsumerState<_CommunityTab> { + final TextEditingController _controller = TextEditingController(); + bool _isSending = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _send() async { + final content = _controller.text.trim(); + if (content.isEmpty) return; + setState(() => _isSending = true); + try { + await ref.read(commentServiceProvider).createComment(widget.concertId, content); + _controller.clear(); + ref.invalidate(concertCommentsProvider(widget.concertId)); + } on Object { + // 전송 실패는 무시하고 입력은 유지한다. + } finally { + if (mounted) setState(() => _isSending = false); + } + } + + @override + Widget build(BuildContext context) { + final commentsAsync = ref.watch(concertCommentsProvider(widget.concertId)); + + return Column( + children: [ + Expanded( + child: commentsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('댓글을 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (comments) { + if (comments.isEmpty) { + return Center( + child: Text('첫 댓글을 남겨보세요', style: LivithTextStyles.body3Regular), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(20), + itemCount: comments.length, + separatorBuilder: (_, _) => const Divider(color: LivithColors.black80), + itemBuilder: (_, index) { + final comment = comments[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + comment.writer, + style: LivithTextStyles.caption1Semibold.copyWith(color: LivithColors.black50), + ), + const SizedBox(height: 4), + Text( + comment.content, + style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.white100), + ), + ], + ); + }, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.white100), + decoration: const InputDecoration(hintText: '댓글을 입력하세요'), + ), + ), + IconButton( + onPressed: _isSending ? null : _send, + icon: const Icon(Icons.send, color: LivithColors.yellow30), + ), + ], + ), + ), + ], + ); + } +} + +class _SetlistTab extends StatelessWidget { + const _SetlistTab({required this.setlistList}); + + final List setlistList; + + @override + Widget build(BuildContext context) { + if (setlistList.isEmpty) { + return Center( + child: Text('등록된 셋리스트가 없어요', style: LivithTextStyles.body3Regular), + ); + } + return ListView( + padding: const EdgeInsets.all(20), + children: [ + for (final setlist in setlistList) ...[ + Text( + setlist.title, + style: LivithTextStyles.body1Semibold.copyWith(color: LivithColors.white100), + ), + const SizedBox(height: 8), + for (final song in setlist.songList) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + song.title, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + ), + trailing: const Icon(Icons.chevron_right, color: LivithColors.black50), + onTap: () => context.push('/song/${song.id}'), + ), + const SizedBox(height: 16), + ], + ], + ); + } +} diff --git a/lib/views/screens/design_system_preview_screen.dart b/lib/views/screens/design_system_preview_screen.dart new file mode 100644 index 0000000..3f2b9f5 --- /dev/null +++ b/lib/views/screens/design_system_preview_screen.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/views/widgets/livith_button.dart'; +import 'package:livith/views/widgets/livith_card.dart'; +import 'package:livith/views/widgets/livith_chip.dart'; +import 'package:livith/views/widgets/livith_modal.dart'; +import 'package:livith/views/widgets/livith_toast.dart'; +import 'package:livith/views/widgets/segmented_tab_bar.dart'; + +/// 디자인 토큰(색상/타이포) 적용을 시각적으로 검증하기 위한 미리보기 화면. +/// +/// 마일스톤 1 검증용이며, 화면 이관이 진행되면 제거한다. +class DesignSystemPreviewScreen extends StatelessWidget { + const DesignSystemPreviewScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Design System')), + body: ListView( + padding: const EdgeInsets.all(20), + children: const [ + _SectionTitle('Colors'), + _ColorSwatches(), + SizedBox(height: 32), + _SectionTitle('Typography'), + _TypographySamples(), + SizedBox(height: 32), + _SectionTitle('Components'), + _ComponentSamples(), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(title, style: LivithTextStyles.headSemibold), + ); + } +} + +class _ColorSwatches extends StatelessWidget { + const _ColorSwatches(); + + static const _entries = <(String, Color)>[ + ('black100', LivithColors.black100), + ('black90', LivithColors.black90), + ('black80', LivithColors.black80), + ('black50', LivithColors.black50), + ('black30', LivithColors.black30), + ('black5', LivithColors.black5), + ('white100', LivithColors.white100), + ('yellow30', LivithColors.yellow30), + ('yellow60', LivithColors.yellow60), + ('caution100', LivithColors.caution100), + ('original', LivithColors.original), + ('translation', LivithColors.translation), + ]; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final (name, color) in _entries) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LivithColors.black80), + ), + ), + const SizedBox(height: 6), + Text(name, style: LivithTextStyles.caption1Regular), + ], + ), + ], + ); + } +} + +class _ComponentSamples extends StatefulWidget { + const _ComponentSamples(); + + @override + State<_ComponentSamples> createState() => _ComponentSamplesState(); +} + +class _ComponentSamplesState extends State<_ComponentSamples> { + int _selectedTab = 0; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Buttons', style: LivithTextStyles.body2Semibold), + const SizedBox(height: 12), + LivithButton('Primary', onPressed: () {}), + const SizedBox(height: 8), + LivithButton('Pink', variant: LivithButtonVariant.pink, onPressed: () {}), + const SizedBox(height: 8), + LivithButton('Secondary', variant: LivithButtonVariant.secondary, onPressed: () {}), + const SizedBox(height: 8), + const LivithButton('Disabled'), + const SizedBox(height: 24), + Text('Chips', style: LivithTextStyles.body2Semibold), + const SizedBox(height: 12), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + LivithChip('status'), + LivithChip('selected', style: LivithChipStyle.selected), + LivithChip('tag', style: LivithChipStyle.tag), + LivithChip('dark', style: LivithChipStyle.dark), + LivithChip('outline', style: LivithChipStyle.outline), + ], + ), + const SizedBox(height: 24), + Text('SegmentedTabBar', style: LivithTextStyles.body2Semibold), + const SizedBox(height: 12), + SegmentedTabBar( + tabs: const ['일정', '셋리스트', '커뮤니티'], + selectedIndex: _selectedTab, + badgeCountList: const [null, null, 12], + onTabSelected: (index) => setState(() => _selectedTab = index), + ), + const SizedBox(height: 24), + Text('Card', style: LivithTextStyles.body2Semibold), + const SizedBox(height: 12), + const LivithCard( + imageUrl: null, + title: '테일러 스위프트 내한', + subtitle: '고척스카이돔', + isSelected: true, + ), + const SizedBox(height: 24), + Text('Modal / Toast', style: LivithTextStyles.body2Semibold), + const SizedBox(height: 12), + LivithButton( + '모달 열기', + isFullWidth: false, + onPressed: () => showLivithModal( + context, + title: '환영합니다', + message: '라이빗에 오신 것을 환영해요', + type: LivithModalType.welcome, + ), + ), + const SizedBox(height: 8), + LivithButton( + '토스트 띄우기', + variant: LivithButtonVariant.secondary, + isFullWidth: false, + onPressed: () => showLivithToast( + context, + type: LivithToastType.success, + message: '관심 공연에 추가했어요', + ), + ), + ], + ); + } +} + +class _TypographySamples extends StatelessWidget { + const _TypographySamples(); + + static const _entries = <(String, TextStyle)>[ + ('title', LivithTextStyles.title), + ('headSemibold', LivithTextStyles.headSemibold), + ('headMedium', LivithTextStyles.headMedium), + ('headRegular', LivithTextStyles.headRegular), + ('body1Semibold', LivithTextStyles.body1Semibold), + ('body2Semibold', LivithTextStyles.body2Semibold), + ('body2Regular', LivithTextStyles.body2Regular), + ('body3Medium', LivithTextStyles.body3Medium), + ('body4Regular', LivithTextStyles.body4Regular), + ('caption1Bold', LivithTextStyles.caption1Bold), + ('caption2Regular', LivithTextStyles.caption2Regular), + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (name, style) in _entries) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text('$name · 라이빗 Livith', style: style), + ), + ], + ); + } +} diff --git a/lib/views/screens/explore_screen.dart b/lib/views/screens/explore_screen.dart new file mode 100644 index 0000000..07e8a14 --- /dev/null +++ b/lib/views/screens/explore_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/concert.dart'; +import 'package:livith/models/search_query.dart'; +import 'package:livith/providers/preference_providers.dart'; +import 'package:livith/views/widgets/livith_card.dart'; +import 'package:livith/views/widgets/livith_chip.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 탐색 화면. +/// +/// iOS `ExploreView`/`SearchView` 대응. 키워드/장르로 콘서트를 검색한다. +class ExploreScreen extends ConsumerStatefulWidget { + const ExploreScreen({super.key}); + + @override + ConsumerState createState() => _ExploreScreenState(); +} + +class _ExploreScreenState extends ConsumerState { + String _keyword = ''; + final Set _genreNameSet = {}; + + SearchQuery get _query => + SearchQuery(keyword: _keyword, genreNameList: _genreNameSet.toList()); + + @override + Widget build(BuildContext context) { + final genresAsync = ref.watch(genresProvider); + final resultsAsync = ref.watch(concertSearchProvider(_query)); + + return Scaffold( + appBar: LivithNavigationBar.logo(), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: TextField( + onChanged: (value) => setState(() => _keyword = value), + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + decoration: const InputDecoration(hintText: '공연·아티스트 검색'), + ), + ), + genresAsync.maybeWhen( + data: (genres) => SizedBox( + height: 42, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + for (final genre in genres) + Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() { + _genreNameSet.contains(genre.name) + ? _genreNameSet.remove(genre.name) + : _genreNameSet.add(genre.name); + }), + child: LivithChip( + genre.name, + style: _genreNameSet.contains(genre.name) + ? LivithChipStyle.selected + : LivithChipStyle.status, + ), + ), + ), + ], + ), + ), + orElse: () => const SizedBox(height: 42), + ), + const SizedBox(height: 12), + Expanded(child: _results(resultsAsync)), + ], + ), + ); + } + + Widget _results(AsyncValue> resultsAsync) { + return resultsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('검색에 실패했어요', style: LivithTextStyles.body3Regular), + ), + data: (concerts) { + if (concerts.isEmpty) { + return Center( + child: Text('검색어나 장르를 선택해보세요', style: LivithTextStyles.body3Regular), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 12, + runSpacing: 16, + children: [ + for (final concert in concerts) + LivithCard( + imageUrl: concert.posterUrl, + title: concert.title, + subtitle: concert.venue, + titleLineLimit: 2, + onTap: () => context.push('/concert/${concert.id}'), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/views/screens/home_screen.dart b/lib/views/screens/home_screen.dart index 2aff1c7..2915aa9 100644 --- a/lib/views/screens/home_screen.dart +++ b/lib/views/screens/home_screen.dart @@ -1,15 +1,113 @@ import 'package:flutter/material.dart'; +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/concert.dart'; +import 'package:livith/view_models/home_view_model.dart'; +import 'package:livith/views/widgets/livith_card.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +/// 홈 화면. +/// +/// iOS `HomeView` 대응. 관심 공연 섹션과 추천 공연 그리드를 표시한다. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final homeAsync = ref.watch(homeViewModelProvider); + return Scaffold( - appBar: AppBar(title: const Text('Livith')), - body: const Center(child: Text('Hello, Riverpod + MVVM!')), + appBar: LivithNavigationBar.logo(), + body: homeAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('공연 정보를 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (state) => RefreshIndicator( + onRefresh: () async => ref.invalidate(homeViewModelProvider), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + if (state.interestConcertList.isNotEmpty) ...[ + const _SectionTitle('관심 공연'), + _ConcertRow(concertList: state.interestConcertList), + const SizedBox(height: 24), + ], + const _SectionTitle('추천 공연'), + _ConcertGrid(concertList: state.recommendedConcertList), + ], + ), + ), + ), ); } } + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + title, + style: LivithTextStyles.body1Semibold.copyWith(color: LivithColors.white100), + ), + ); + } +} + +class _ConcertRow extends StatelessWidget { + const _ConcertRow({required this.concertList}); + + final List concertList; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 220, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: concertList.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) => _card(context, concertList[index]), + ), + ); + } +} + +class _ConcertGrid extends StatelessWidget { + const _ConcertGrid({required this.concertList}); + + final List concertList; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 12, + runSpacing: 16, + children: [for (final concert in concertList) _card(context, concert)], + ), + ); + } +} + +Widget _card(BuildContext context, Concert concert) { + return LivithCard( + imageUrl: concert.posterUrl, + title: concert.title, + subtitle: concert.venue, + titleLineLimit: 2, + onTap: () => context.push('/concert/${concert.id}'), + ); +} diff --git a/lib/views/screens/login_screen.dart b/lib/views/screens/login_screen.dart new file mode 100644 index 0000000..374c056 --- /dev/null +++ b/lib/views/screens/login_screen.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/social_provider.dart'; +import 'package:livith/providers/integration_providers.dart'; +import 'package:livith/view_models/auth_view_model.dart'; +import 'package:livith/views/widgets/livith_button.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 화면. +/// +/// iOS `LoginView` 대응. 소셜 로그인 버튼을 노출하고 [AuthViewModel]로 로그인한다. +class LoginScreen extends ConsumerWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = ref.watch(authViewModelProvider).isLoading; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const Spacer(), + Image.asset('assets/images/livith_logo.png', height: 36), + const SizedBox(height: 8), + Text( + '내한 공연의 모든 것', + style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.black50), + ), + const Spacer(), + LivithButton( + '카카오로 시작하기', + isLoading: isLoading, + onPressed: () => _login(ref, SocialProvider.kakao), + ), + const SizedBox(height: 12), + LivithButton( + 'Apple로 시작하기', + variant: LivithButtonVariant.secondary, + isLoading: isLoading, + onPressed: () => _login(ref, SocialProvider.apple), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } + + void _login(WidgetRef ref, SocialProvider provider) { + ref.read(analyticsServiceProvider).track('click_login', {'provider': provider.value}); + ref.read(authViewModelProvider.notifier).loginWith(provider); + } +} diff --git a/lib/views/screens/main_tab_screen.dart b/lib/views/screens/main_tab_screen.dart new file mode 100644 index 0000000..5007c7b --- /dev/null +++ b/lib/views/screens/main_tab_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/views/screens/explore_screen.dart'; +import 'package:livith/views/screens/home_screen.dart'; +import 'package:livith/views/screens/user_screen.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// 메인 탭(홈/탐색/마이) 컨테이너. +/// +/// iOS `LivithMainTabView` 대응. +class MainTabScreen extends ConsumerStatefulWidget { + const MainTabScreen({super.key}); + + @override + ConsumerState createState() => _MainTabScreenState(); +} + +class _MainTabScreenState extends ConsumerState { + int _index = 0; + + static const _screens = [HomeScreen(), ExploreScreen(), UserScreen()]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _index, children: _screens), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _index, + onTap: (index) => setState(() => _index = index), + backgroundColor: LivithColors.black90, + selectedItemColor: LivithColors.yellow30, + unselectedItemColor: LivithColors.black50, + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: _icon('assets/icons/home_disabled.svg'), + activeIcon: _icon('assets/icons/home_enabled.svg'), + label: '홈', + ), + BottomNavigationBarItem( + icon: _icon('assets/icons/search.svg', color: LivithColors.black50), + activeIcon: _icon('assets/icons/search.svg', color: LivithColors.yellow30), + label: '탐색', + ), + BottomNavigationBarItem( + icon: _icon('assets/icons/my_disabled.svg'), + activeIcon: _icon('assets/icons/my_enabled.svg'), + label: '마이', + ), + ], + ), + ); + } + + Widget _icon(String asset, {Color? color}) { + return SvgPicture.asset( + asset, + width: 24, + height: 24, + colorFilter: color == null ? null : ColorFilter.mode(color, BlendMode.srcIn), + ); + } +} diff --git a/lib/views/screens/nickname_edit_screen.dart b/lib/views/screens/nickname_edit_screen.dart new file mode 100644 index 0000000..e44e82d --- /dev/null +++ b/lib/views/screens/nickname_edit_screen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/nickname.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/providers/user_providers.dart'; +import 'package:livith/views/widgets/livith_button.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; +import 'package:livith/views/widgets/livith_toast.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 닉네임 수정 화면. +/// +/// iOS `NicknameUpdateView` 대응. +class NicknameEditScreen extends ConsumerStatefulWidget { + const NicknameEditScreen({super.key}); + + @override + ConsumerState createState() => _NicknameEditScreenState(); +} + +class _NicknameEditScreenState extends ConsumerState { + String _nickname = ''; + bool _isSaving = false; + + @override + Widget build(BuildContext context) { + final isValid = Nickname.isValid(_nickname); + + return Scaffold( + appBar: LivithNavigationBar.back(title: '닉네임 수정', onBack: () => context.pop()), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + TextField( + onChanged: (value) => setState(() => _nickname = value), + maxLength: 10, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + decoration: const InputDecoration( + hintText: '영문/숫자/한글 1~10자', + counterText: '', + ), + ), + const Spacer(), + LivithButton( + '저장', + isLoading: _isSaving, + onPressed: isValid ? _save : null, + ), + ], + ), + ), + ); + } + + Future _save() async { + setState(() => _isSaving = true); + try { + await ref.read(userServiceProvider).updateNickname(_nickname); + ref.invalidate(userProfileProvider); + if (!mounted) return; + context.pop(); + } on Object { + if (!mounted) return; + showLivithToast(context, type: LivithToastType.failure, message: '닉네임 변경에 실패했어요'); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } +} diff --git a/lib/views/screens/notice_setting_screen.dart b/lib/views/screens/notice_setting_screen.dart new file mode 100644 index 0000000..a24a6db --- /dev/null +++ b/lib/views/screens/notice_setting_screen.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/providers/integration_providers.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 알림 설정 화면. +/// +/// iOS `NoticeSettingView` 대응. 마케팅 동의만 서버 연동(현재 stub), +/// 세부 알림 토글은 UI만 제공(저장 연동은 후속). +class NoticeSettingScreen extends ConsumerStatefulWidget { + const NoticeSettingScreen({super.key}); + + @override + ConsumerState createState() => _NoticeSettingScreenState(); +} + +class _NoticeSettingScreenState extends ConsumerState { + bool _marketing = false; + bool _night = false; + bool _ticketSchedule = true; + bool _concertInfo = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: LivithNavigationBar.back(title: '알림 설정', onBack: () => context.pop()), + body: ListView( + children: [ + _toggle('마케팅 정보 수신', _marketing, (value) { + setState(() => _marketing = value); + ref.read(notificationServiceProvider).updateMarketingConsent(agreed: value); + }), + _toggle('야간 알림', _night, (value) => setState(() => _night = value)), + _toggle('예매 일정 알림', _ticketSchedule, (value) => setState(() => _ticketSchedule = value)), + _toggle('공연 정보 업데이트', _concertInfo, (value) => setState(() => _concertInfo = value)), + ], + ), + ); + } + + Widget _toggle(String label, bool value, ValueChanged onChanged) { + return SwitchListTile( + value: value, + onChanged: onChanged, + activeThumbColor: LivithColors.yellow30, + title: Text( + label, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + ), + ); + } +} diff --git a/lib/views/screens/onboarding_screen.dart b/lib/views/screens/onboarding_screen.dart new file mode 100644 index 0000000..c8d4096 --- /dev/null +++ b/lib/views/screens/onboarding_screen.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/nickname.dart'; +import 'package:livith/providers/preference_providers.dart'; +import 'package:livith/view_models/auth_view_model.dart'; +import 'package:livith/view_models/onboarding_view_model.dart'; +import 'package:livith/views/widgets/livith_button.dart'; +import 'package:livith/views/widgets/livith_chip.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 온보딩(가입) 흐름: 약관 → 닉네임 → 선호 장르 → 선호 아티스트 → 가입. +/// +/// iOS 온보딩 흐름 대응. 4단계를 [PageView]로 진행한다. +class OnboardingScreen extends ConsumerStatefulWidget { + const OnboardingScreen({super.key}); + + @override + ConsumerState createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends ConsumerState { + final PageController _pageController = PageController(); + int _step = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final authState = ref.read(authViewModelProvider).value; + if (authState is OnboardingRequired) { + ref.read(onboardingViewModelProvider.notifier).initialize(authState.tempUser); + } + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _next() { + if (_step >= 3) return; + setState(() => _step++); + _pageController.animateToPage( + _step, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + } + + Future _submit() async { + await ref.read(onboardingViewModelProvider.notifier).submit(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _TermsStep(onNext: _next), + _NicknameStep(onNext: _next), + _GenreStep(onNext: _next), + _ArtistStep(onSubmit: _submit), + ], + ), + ), + ); + } +} + +class _StepScaffold extends StatelessWidget { + const _StepScaffold({required this.title, required this.content, required this.footer}); + + final String title; + final Widget content; + final Widget footer; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Text(title, style: LivithTextStyles.headSemibold.copyWith(color: LivithColors.white100)), + const SizedBox(height: 24), + Expanded(child: content), + footer, + ], + ), + ); + } +} + +class _TermsStep extends ConsumerWidget { + const _TermsStep({required this.onNext}); + + final VoidCallback onNext; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(onboardingViewModelProvider); + final notifier = ref.read(onboardingViewModelProvider.notifier); + + return _StepScaffold( + title: '약관에 동의해주세요', + content: Column( + children: [ + CheckboxListTile( + value: state.agreedTerms, + onChanged: (value) => notifier.setAgreedTerms( + agreed: value ?? false, + marketing: state.marketingAgreed, + ), + title: const Text('(필수) 서비스 이용약관 동의'), + ), + CheckboxListTile( + value: state.marketingAgreed, + onChanged: (value) => notifier.setAgreedTerms( + agreed: state.agreedTerms, + marketing: value ?? false, + ), + title: const Text('(선택) 마케팅 정보 수신 동의'), + ), + ], + ), + footer: LivithButton('다음', onPressed: state.agreedTerms ? onNext : null), + ); + } +} + +class _NicknameStep extends ConsumerWidget { + const _NicknameStep({required this.onNext}); + + final VoidCallback onNext; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nickname = ref.watch(onboardingViewModelProvider).nickname; + final isValid = Nickname.isValid(nickname); + + return _StepScaffold( + title: '닉네임을 입력해주세요', + content: TextField( + onChanged: ref.read(onboardingViewModelProvider.notifier).setNickname, + maxLength: 10, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + decoration: const InputDecoration( + hintText: '영문/숫자/한글 1~10자', + counterText: '', + ), + ), + footer: LivithButton('다음', onPressed: isValid ? onNext : null), + ); + } +} + +class _GenreStep extends ConsumerWidget { + const _GenreStep({required this.onNext}); + + final VoidCallback onNext; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final genresAsync = ref.watch(genresProvider); + final selected = ref.watch(onboardingViewModelProvider).genreIdList; + final notifier = ref.read(onboardingViewModelProvider.notifier); + + return _StepScaffold( + title: '선호하는 장르를 골라주세요', + content: genresAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('장르를 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (genres) => SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final genre in genres) + GestureDetector( + onTap: () => notifier.toggleGenre(genre.id), + child: LivithChip( + genre.name, + style: selected.contains(genre.id) + ? LivithChipStyle.selected + : LivithChipStyle.status, + ), + ), + ], + ), + ), + ), + footer: LivithButton('다음', onPressed: selected.isNotEmpty ? onNext : null), + ); + } +} + +class _ArtistStep extends ConsumerStatefulWidget { + const _ArtistStep({required this.onSubmit}); + + final Future Function() onSubmit; + + @override + ConsumerState<_ArtistStep> createState() => _ArtistStepState(); +} + +class _ArtistStepState extends ConsumerState<_ArtistStep> { + String _keyword = ''; + + @override + Widget build(BuildContext context) { + final results = ref.watch(artistSearchProvider(_keyword)); + final selected = ref.watch(onboardingViewModelProvider).artistIdList; + final notifier = ref.read(onboardingViewModelProvider.notifier); + final isSubmitting = ref.watch(authViewModelProvider).isLoading; + + return _StepScaffold( + title: '좋아하는 아티스트를 골라주세요', + content: Column( + children: [ + TextField( + onChanged: (value) => setState(() => _keyword = value), + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + decoration: const InputDecoration(hintText: '아티스트 검색'), + ), + const SizedBox(height: 16), + Expanded( + child: results.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('검색에 실패했어요', style: LivithTextStyles.body3Regular), + ), + data: (artists) => ListView( + children: [ + for (final artist in artists) + CheckboxListTile( + value: selected.contains(artist.id), + onChanged: (_) => notifier.toggleArtist(artist.id), + title: Text(artist.name), + ), + ], + ), + ), + ), + ], + ), + footer: LivithButton( + '가입 완료', + isLoading: isSubmitting, + onPressed: selected.isNotEmpty ? () => widget.onSubmit() : null, + ), + ); + } +} diff --git a/lib/views/screens/setting_screen.dart b/lib/views/screens/setting_screen.dart new file mode 100644 index 0000000..5bf78a8 --- /dev/null +++ b/lib/views/screens/setting_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/view_models/auth_view_model.dart'; +import 'package:livith/views/widgets/livith_button.dart'; +import 'package:livith/views/widgets/livith_modal.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 설정 화면. +/// +/// iOS `SettingView` 대응. 로그아웃/회원탈퇴를 제공한다. +class SettingScreen extends ConsumerWidget { + const SettingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: LivithNavigationBar.back(title: '설정', onBack: () => context.pop()), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('알림 설정'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/notice-setting'), + ), + const Spacer(), + LivithButton( + '로그아웃', + variant: LivithButtonVariant.secondary, + onPressed: () => ref.read(authViewModelProvider.notifier).logout(), + ), + const SizedBox(height: 12), + LivithButton( + '회원 탈퇴', + variant: LivithButtonVariant.pink, + onPressed: () => _confirmWithdraw(context, ref), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + void _confirmWithdraw(BuildContext context, WidgetRef ref) { + showLivithModal( + context, + title: '정말 탈퇴하시겠어요?', + message: '탈퇴 시 모든 정보가 삭제됩니다', + type: LivithModalType.error, + confirmTitle: '탈퇴하기', + onConfirm: () async { + try { + await ref.read(authServiceProvider).withdraw('회원 탈퇴'); + } on Object { + // 서버 실패와 무관하게 로컬 로그아웃 처리한다. + } + await ref.read(authViewModelProvider.notifier).logout(); + }, + ); + } +} diff --git a/lib/views/screens/song_lyrics_screen.dart b/lib/views/screens/song_lyrics_screen.dart new file mode 100644 index 0000000..6452582 --- /dev/null +++ b/lib/views/screens/song_lyrics_screen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/models/song_lyrics.dart'; +import 'package:livith/providers/concert_detail_providers.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; +import 'package:livith/views/widgets/segmented_tab_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 곡 가사 화면. +/// +/// iOS `SongLyricsView` 대응. 원문/발음/번역을 전환해 표시한다. +class SongLyricsScreen extends ConsumerStatefulWidget { + const SongLyricsScreen({super.key, required this.songId}); + + final int songId; + + @override + ConsumerState createState() => _SongLyricsScreenState(); +} + +class _SongLyricsScreenState extends ConsumerState { + int _mode = 0; + + @override + Widget build(BuildContext context) { + final lyricsAsync = ref.watch(songLyricsProvider(widget.songId)); + + return Scaffold( + appBar: LivithNavigationBar.back(title: '가사', onBack: () => context.pop()), + body: lyricsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('가사를 불러오지 못했어요', style: LivithTextStyles.body3Regular), + ), + data: (lyrics) => Column( + children: [ + SegmentedTabBar( + tabs: const ['원문', '발음', '번역'], + selectedIndex: _mode, + onTabSelected: (index) => setState(() => _mode = index), + ), + Expanded(child: _lyricList(lyrics)), + ], + ), + ), + ); + } + + Widget _lyricList(SongLyrics lyrics) { + final lines = switch (_mode) { + 1 => lyrics.pronunciationList, + 2 => lyrics.translationList, + _ => lyrics.lyricList, + }; + if (lines.isEmpty) { + return Center( + child: Text('해당 가사가 없어요', style: LivithTextStyles.body3Regular), + ); + } + return ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: lines.length, + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + lines[index], + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + ), + ), + ); + } +} diff --git a/lib/views/screens/user_screen.dart b/lib/views/screens/user_screen.dart new file mode 100644 index 0000000..0ebb9c4 --- /dev/null +++ b/lib/views/screens/user_screen.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/providers/user_providers.dart'; +import 'package:livith/views/widgets/livith_navigation_bar.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 마이 화면. +/// +/// iOS `UserView` 대응. 프로필과 설정 진입을 제공한다. +class UserScreen extends ConsumerWidget { + const UserScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profileAsync = ref.watch(userProfileProvider); + + return Scaffold( + appBar: LivithNavigationBar.logo(), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + profileAsync.when( + loading: () => const SizedBox( + height: 60, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Text( + '프로필을 불러오지 못했어요', + style: LivithTextStyles.body3Regular.copyWith(color: LivithColors.black50), + ), + data: (user) => Text( + user.nickname, + style: LivithTextStyles.title.copyWith(color: LivithColors.white100), + ), + ), + const SizedBox(height: 32), + _MenuItem(label: '닉네임 수정', onTap: () => context.push('/nickname-edit')), + _MenuItem(label: '설정', onTap: () => context.push('/setting')), + ], + ), + ); + } +} + +class _MenuItem extends StatelessWidget { + const _MenuItem({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + label, + style: LivithTextStyles.body2Regular.copyWith(color: LivithColors.white100), + ), + trailing: const Icon(Icons.chevron_right, color: LivithColors.black50), + onTap: onTap, + ); + } +} diff --git a/lib/views/widgets/async_image_view.dart b/lib/views/widgets/async_image_view.dart new file mode 100644 index 0000000..3b74ae6 --- /dev/null +++ b/lib/views/widgets/async_image_view.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +/// 네트워크 이미지를 캐싱하여 표시하는 위젯. +/// +/// iOS `AsyncImageView`(Kingfisher 기반) 대응. 로드 실패/대기 시 `black90` 배경을 표시하고, +/// `showGradient`가 true이면 하단에서 상단으로 향하는 `black100` 그라데이션을 덧씌운다. +class AsyncImageView extends StatelessWidget { + const AsyncImageView({ + super.key, + required this.url, + this.fit = BoxFit.cover, + this.showGradient = false, + }); + + final String? url; + final BoxFit fit; + final bool showGradient; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + _image(), + if (showGradient) + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [LivithColors.black100, Colors.transparent], + ), + ), + ), + ], + ); + } + + Widget _image() { + final imageUrl = url; + if (imageUrl == null || imageUrl.isEmpty) { + return const ColoredBox(color: LivithColors.black90); + } + + return CachedNetworkImage( + imageUrl: imageUrl, + fit: fit, + placeholder: (_, _) => const ColoredBox(color: LivithColors.black90), + errorWidget: (_, _, _) => const ColoredBox(color: LivithColors.black90), + ); + } +} diff --git a/lib/views/widgets/livith_button.dart b/lib/views/widgets/livith_button.dart new file mode 100644 index 0000000..eec0c5c --- /dev/null +++ b/lib/views/widgets/livith_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +enum LivithButtonVariant { primary, pink, secondary } + +/// Livith 메인 버튼. +/// +/// iOS `LivithButton` 대응. 높이 52, 코너 6, `body3Semibold`. +class LivithButton extends StatefulWidget { + const LivithButton( + this.title, { + super.key, + this.variant = LivithButtonVariant.primary, + this.isFullWidth = true, + this.isLoading = false, + this.cornerRadius = 6, + this.onPressed, + }); + + final String title; + final LivithButtonVariant variant; + final bool isFullWidth; + final bool isLoading; + final double cornerRadius; + final VoidCallback? onPressed; + + @override + State createState() => _LivithButtonState(); +} + +class _LivithButtonState extends State { + bool _pressed = false; + + bool get _enabled => widget.onPressed != null && !widget.isLoading; + + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: 52, + width: widget.isFullWidth ? double.infinity : null, + child: GestureDetector( + onTapDown: _enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: _enabled ? (_) => setState(() => _pressed = false) : null, + onTapCancel: _enabled ? () => setState(() => _pressed = false) : null, + onTap: _enabled ? widget.onPressed : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: _backgroundColor, + borderRadius: BorderRadius.circular(widget.cornerRadius), + ), + child: Center(child: _label), + ), + ), + ); + return child; + } + + Widget get _label { + if (widget.isLoading) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: LivithColors.black100), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + widget.title, + style: LivithTextStyles.body3Semibold.copyWith(color: _foregroundColor), + ), + ); + } + + Color get _backgroundColor { + if (!_enabled) return LivithColors.black80; + switch (widget.variant) { + case LivithButtonVariant.primary: + return _pressed ? LivithColors.yellow60 : LivithColors.yellow30; + case LivithButtonVariant.pink: + return _pressed + ? LivithColors.translation.withValues(alpha: 0.8) + : LivithColors.translation; + case LivithButtonVariant.secondary: + return _pressed ? LivithColors.black80 : LivithColors.black50; + } + } + + Color get _foregroundColor { + if (!_enabled) return LivithColors.black50; + return switch (widget.variant) { + LivithButtonVariant.primary || LivithButtonVariant.pink => LivithColors.black100, + LivithButtonVariant.secondary => LivithColors.white100, + }; + } +} diff --git a/lib/views/widgets/livith_card.dart b/lib/views/widgets/livith_card.dart new file mode 100644 index 0000000..1d28b58 --- /dev/null +++ b/lib/views/widgets/livith_card.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/views/widgets/async_image_view.dart'; + +/// Livith 콘서트 카드. +/// +/// iOS `LivithCard` 대응. 포스터 이미지(108x158, 코너 6) + 제목/부제. +/// 선택 시 `yellow30` 테두리(2pt)를 표시한다. +class LivithCard extends StatelessWidget { + const LivithCard({ + super.key, + required this.imageUrl, + required this.title, + this.subtitle, + this.secondaryText, + this.isSelected = false, + this.titleLineLimit, + this.onTap, + }); + + final String? imageUrl; + final String title; + final String? subtitle; + final String? secondaryText; + final bool isSelected; + final int? titleLineLimit; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final hasSubtitle = subtitle != null; + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 108, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 108, + height: 158, + foregroundDecoration: isSelected + ? BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: LivithColors.yellow30, width: 2), + ) + : null, + child: AsyncImageView(url: imageUrl), + ), + ), + SizedBox(height: hasSubtitle ? 6 : 8), + Text( + title, + maxLines: titleLineLimit, + overflow: titleLineLimit == null ? null : TextOverflow.ellipsis, + style: LivithTextStyles.body2Medium.copyWith(color: LivithColors.white100), + ), + if (hasSubtitle) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: LivithTextStyles.caption1Semibold.copyWith(color: LivithColors.black50), + ), + ], + if (secondaryText != null) + Text( + secondaryText!, + style: LivithTextStyles.caption1Semibold.copyWith(color: LivithColors.black50), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/widgets/livith_chip.dart b/lib/views/widgets/livith_chip.dart new file mode 100644 index 0000000..d7c3b9b --- /dev/null +++ b/lib/views/widgets/livith_chip.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +enum LivithChipStyle { status, selected, tag, dark, outline } + +/// Livith 칩. +/// +/// iOS `LivithChip` 대응. 폰트 `caption1Bold`, 높이 30. +class LivithChip extends StatelessWidget { + const LivithChip(this.text, {super.key, this.style = LivithChipStyle.status}); + + final String text; + final LivithChipStyle style; + + @override + Widget build(BuildContext context) { + final spec = _spec; + return Container( + padding: EdgeInsets.symmetric( + horizontal: spec.horizontalPadding, + vertical: spec.verticalPadding, + ), + decoration: BoxDecoration( + color: spec.background, + borderRadius: BorderRadius.circular(spec.radius), + border: spec.borderColor == null + ? null + : Border.all(color: spec.borderColor!), + ), + child: Text( + text, + style: LivithTextStyles.caption1Bold.copyWith(color: spec.textColor), + ), + ); + } + + _ChipSpec get _spec { + return switch (style) { + LivithChipStyle.status => const _ChipSpec( + background: LivithColors.black90, + textColor: LivithColors.black30, + radius: 24, + horizontalPadding: 12, + verticalPadding: 7, + ), + LivithChipStyle.selected => const _ChipSpec( + background: LivithColors.yellow30, + textColor: LivithColors.black100, + radius: 24, + horizontalPadding: 12, + verticalPadding: 7, + ), + LivithChipStyle.tag => const _ChipSpec( + background: LivithColors.black80, + textColor: LivithColors.black30, + radius: 16, + horizontalPadding: 12, + verticalPadding: 8, + ), + LivithChipStyle.dark => const _ChipSpec( + background: LivithColors.black100, + textColor: LivithColors.black50, + radius: 24, + horizontalPadding: 9, + verticalPadding: 4, + ), + LivithChipStyle.outline => const _ChipSpec( + background: Colors.transparent, + textColor: LivithColors.black50, + radius: 24, + horizontalPadding: 12, + verticalPadding: 4, + borderColor: LivithColors.black50, + ), + }; + } +} + +final class _ChipSpec { + const _ChipSpec({ + required this.background, + required this.textColor, + required this.radius, + required this.horizontalPadding, + required this.verticalPadding, + this.borderColor, + }); + + final Color background; + final Color textColor; + final double radius; + final double horizontalPadding; + final double verticalPadding; + final Color? borderColor; +} diff --git a/lib/views/widgets/livith_modal.dart b/lib/views/widgets/livith_modal.dart new file mode 100644 index 0000000..6635168 --- /dev/null +++ b/lib/views/widgets/livith_modal.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; +import 'package:livith/views/widgets/livith_button.dart'; + +enum LivithModalType { normal, welcome, error } + +/// Livith 알림 모달. +/// +/// iOS `LivithModal` 대응. 너비 328, 코너 12, 배경 `black90`, 내부 패딩 16. +/// 헤더 아이콘은 마일스톤 1에서 Material 아이콘으로 대체한다. +class LivithModal extends StatelessWidget { + const LivithModal({ + super.key, + required this.title, + this.message, + this.confirmTitle = '확인', + this.type = LivithModalType.normal, + this.onConfirm, + }); + + final String title; + final String? message; + final String confirmTitle; + final LivithModalType type; + final VoidCallback? onConfirm; + + @override + Widget build(BuildContext context) { + return Container( + width: 328, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: LivithColors.black90, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_headerIcon != null) ...[ + Icon(_headerIcon, size: 40, color: LivithColors.yellow30), + const SizedBox(height: 8), + ] else + const SizedBox(height: 8), + Text( + title, + textAlign: TextAlign.center, + style: LivithTextStyles.body1Semibold.copyWith(color: LivithColors.white100), + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + textAlign: TextAlign.center, + style: LivithTextStyles.body4Regular.copyWith(color: LivithColors.black30), + ), + ], + const SizedBox(height: 20), + LivithButton( + confirmTitle, + variant: type == LivithModalType.error + ? LivithButtonVariant.pink + : LivithButtonVariant.primary, + cornerRadius: 4, + onPressed: onConfirm ?? () {}, + ), + ], + ), + ); + } + + IconData? get _headerIcon { + return switch (type) { + LivithModalType.welcome => Icons.celebration_outlined, + LivithModalType.error => Icons.error_outline, + LivithModalType.normal => null, + }; + } +} + +/// [LivithModal]을 다이얼로그로 표시한다. +Future showLivithModal( + BuildContext context, { + required String title, + String? message, + String confirmTitle = '확인', + LivithModalType type = LivithModalType.normal, + VoidCallback? onConfirm, +}) { + return showDialog( + context: context, + builder: (dialogContext) => Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: LivithModal( + title: title, + message: message, + confirmTitle: confirmTitle, + type: type, + onConfirm: () { + Navigator.of(dialogContext).pop(); + onConfirm?.call(); + }, + ), + ), + ); +} diff --git a/lib/views/widgets/livith_navigation_bar.dart b/lib/views/widgets/livith_navigation_bar.dart new file mode 100644 index 0000000..9b26f21 --- /dev/null +++ b/lib/views/widgets/livith_navigation_bar.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +enum _NavType { logo, back, backOnly } + +/// Livith 공통 네비게이션 헤더. +/// +/// iOS `LivithNavigationView` 대응. `Scaffold.appBar`에 사용한다. +/// 로고/알림/뒤로가기 아이콘은 마일스톤 1에서 텍스트·Material 아이콘으로 대체한다. +class LivithNavigationBar extends StatelessWidget implements PreferredSizeWidget { + const LivithNavigationBar._( + this._type, { + this.hasNewNotice = false, + this.onNoticeTap, + this.title, + this.onBack, + this.rightButtonTitle, + this.onRightButtonTap, + }); + + /// 홈/탐색 탭의 로고 헤더. + factory LivithNavigationBar.logo({ + bool hasNewNotice = false, + VoidCallback? onNoticeTap, + }) { + return LivithNavigationBar._( + _NavType.logo, + hasNewNotice: hasNewNotice, + onNoticeTap: onNoticeTap, + ); + } + + /// 제목 + 뒤로가기 + (선택) 우측 텍스트 버튼 헤더. + factory LivithNavigationBar.back({ + required String title, + required VoidCallback onBack, + String? rightButtonTitle, + VoidCallback? onRightButtonTap, + }) { + return LivithNavigationBar._( + _NavType.back, + title: title, + onBack: onBack, + rightButtonTitle: rightButtonTitle, + onRightButtonTap: onRightButtonTap, + ); + } + + /// 뒤로가기 버튼만 있는 헤더. + factory LivithNavigationBar.backOnly({required VoidCallback onBack}) { + return LivithNavigationBar._(_NavType.backOnly, onBack: onBack); + } + + final _NavType _type; + final bool hasNewNotice; + final VoidCallback? onNoticeTap; + final String? title; + final VoidCallback? onBack; + final String? rightButtonTitle; + final VoidCallback? onRightButtonTap; + + @override + Size get preferredSize => Size.fromHeight(_type == _NavType.logo ? 60 : 66); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: LivithColors.black100, + child: SafeArea(bottom: false, child: _content()), + ); + } + + Widget _content() { + return switch (_type) { + _NavType.logo => _logoContent(), + _NavType.back => _backContent(), + _NavType.backOnly => _backOnlyContent(), + }; + } + + Widget _logoContent() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Image.asset('assets/images/livith_logo.png', height: 24), + IconButton( + onPressed: onNoticeTap, + icon: Icon( + Icons.notifications_outlined, + color: hasNewNotice ? LivithColors.yellow30 : LivithColors.white100, + ), + ), + ], + ), + ); + } + + Widget _backContent() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + _backButton(), + const SizedBox(width: 4), + Expanded( + child: Text( + title ?? '', + textAlign: TextAlign.center, + style: LivithTextStyles.body1Semibold.copyWith(color: LivithColors.white100), + ), + ), + const SizedBox(width: 4), + if (rightButtonTitle != null) + GestureDetector( + onTap: onRightButtonTap, + child: Text( + rightButtonTitle!, + style: LivithTextStyles.body4Regular.copyWith(color: LivithColors.black50), + ), + ) + else + const SizedBox(width: 36), + ], + ), + ); + } + + Widget _backOnlyContent() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Align(alignment: Alignment.centerLeft, child: _backButton()), + ); + } + + Widget _backButton() { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new, color: LivithColors.white100, size: 20), + ), + ); + } +} diff --git a/lib/views/widgets/livith_toast.dart b/lib/views/widgets/livith_toast.dart new file mode 100644 index 0000000..dedd7b6 --- /dev/null +++ b/lib/views/widgets/livith_toast.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +enum LivithToastType { success, failure } + +/// Livith 토스트. +/// +/// iOS `LivithToast` 대응. 너비 343, 코너 8, 배경 `black80`. +/// 아이콘은 마일스톤 1에서 Material 아이콘으로 대체한다. +class LivithToast extends StatelessWidget { + const LivithToast({super.key, required this.type, required this.message}); + + final LivithToastType type; + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: 343, + padding: const EdgeInsets.fromLTRB(20, 12, 12, 12), + decoration: BoxDecoration( + color: LivithColors.black80, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: LivithColors.black100.withValues(alpha: 0.4), + blurRadius: 18, + ), + ], + ), + child: Row( + children: [ + Icon(_icon, size: 30, color: _iconColor), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: LivithTextStyles.body4Semibold.copyWith(color: LivithColors.white100), + ), + ), + ], + ), + ); + } + + IconData get _icon => switch (type) { + LivithToastType.success => Icons.check_circle, + LivithToastType.failure => Icons.warning_amber_rounded, + }; + + Color get _iconColor => switch (type) { + LivithToastType.success => LivithColors.yellow30, + LivithToastType.failure => LivithColors.caution100, + }; +} + +/// [LivithToast]를 화면 하단에 띄우고 [duration] 후 자동으로 제거한다. +void showLivithToast( + BuildContext context, { + required LivithToastType type, + required String message, + Duration duration = const Duration(seconds: 2), +}) { + final overlay = Overlay.of(context); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (entryContext) => Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(entryContext).padding.bottom + 32, + child: Center(child: LivithToast(type: type, message: message)), + ), + ); + + overlay.insert(entry); + Future.delayed(duration, entry.remove); +} diff --git a/lib/views/widgets/segmented_tab_bar.dart b/lib/views/widgets/segmented_tab_bar.dart new file mode 100644 index 0000000..bc2fd4d --- /dev/null +++ b/lib/views/widgets/segmented_tab_bar.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:livith/core/theme/livith_colors.dart'; +import 'package:livith/core/theme/livith_typography.dart'; + +/// 세그먼트 탭 선택기. +/// +/// iOS `SegmentedTabBar` 대응. 하단 경계선(`black90`, 3pt) 위에 선택 탭만 +/// `white100` 인디케이터(3pt)를 표시한다. 탭별 배지 카운트를 지원한다. +class SegmentedTabBar extends StatelessWidget { + const SegmentedTabBar({ + super.key, + required this.tabs, + required this.selectedIndex, + required this.onTabSelected, + this.badgeCountList, + this.isScrollable = false, + this.tabWidth, + }); + + final List tabs; + final int selectedIndex; + final ValueChanged onTabSelected; + final List? badgeCountList; + final bool isScrollable; + final double? tabWidth; + + @override + Widget build(BuildContext context) { + final row = Row( + mainAxisSize: isScrollable ? MainAxisSize.min : MainAxisSize.max, + children: [ + for (var index = 0; index < tabs.length; index++) + _buildTab(index), + ], + ); + + return ColoredBox( + color: LivithColors.black100, + child: isScrollable + ? SingleChildScrollView(scrollDirection: Axis.horizontal, child: row) + : row, + ); + } + + Widget _buildTab(int index) { + final isSelected = index == selectedIndex; + final badgeCount = badgeCountList != null && index < badgeCountList!.length + ? badgeCountList![index] + : null; + + final tab = GestureDetector( + onTap: () => onTabSelected(index), + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tabs[index], + style: LivithTextStyles.body2Semibold.copyWith( + color: isSelected ? LivithColors.white100 : LivithColors.black50, + ), + ), + if (badgeCount != null) ...[ + const SizedBox(width: 2), + Text( + '$badgeCount', + style: LivithTextStyles.body2Semibold.copyWith(color: LivithColors.yellow30), + ), + ], + ], + ), + ), + Container( + height: 3, + color: isSelected ? LivithColors.white100 : LivithColors.black90, + ), + ], + ), + ); + + if (isScrollable) { + return SizedBox(width: tabWidth ?? 106, child: tab); + } + return Expanded(child: tab); + } +} diff --git a/pubspec.lock b/pubspec.lock index 13ba382..0f8326c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -41,6 +49,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -65,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + url: "https://pub.dev" + source: hosted + version: "1.1.0" collection: dependency: transitive description: @@ -105,6 +145,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" fake_async: dependency: transitive description: @@ -113,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -121,11 +193,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -142,11 +230,72 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -163,6 +312,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" + url: "https://pub.dev" + source: hosted + version: "17.2.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -187,6 +360,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + kakao_flutter_sdk_auth: + dependency: transitive + description: + name: kakao_flutter_sdk_auth + sha256: a1f084dace56050fc3bc12cea9504d82d99977153ce9beb6d7c1bb191f92f235 + url: "https://pub.dev" + source: hosted + version: "2.0.0+1" + kakao_flutter_sdk_common: + dependency: transitive + description: + name: kakao_flutter_sdk_common + sha256: cc8685d91d3f741aed1e1ba4b54bca283cb929e60f61c91bbe9355e93b62d35b + url: "https://pub.dev" + source: hosted + version: "2.0.0+1" + kakao_flutter_sdk_user: + dependency: "direct main" + description: + name: kakao_flutter_sdk_user + sha256: c5420bc08413f172d52332a8270237bb249066a9a82338b301441785d28248e1 + url: "https://pub.dev" + source: hosted + version: "2.0.0+1" leak_tracker: dependency: transitive description: @@ -267,6 +496,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -283,6 +528,94 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: @@ -299,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -307,6 +648,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -368,6 +773,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -400,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -440,6 +893,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e + url: "https://pub.dev" + source: hosted + version: "1.2.3" vector_math: dependency: transitive description: @@ -496,6 +981,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: @@ -506,4 +1015,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 41828df..532a89d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,12 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_riverpod: ^3.3.1 + dio: ^5.9.2 + cached_network_image: ^3.4.1 + flutter_secure_storage: ^10.2.0 + go_router: ^17.2.3 + flutter_svg: ^2.3.0 + kakao_flutter_sdk_user: ^2.0.0+1 dev_dependencies: flutter_test: @@ -58,6 +64,42 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/images/ + - assets/icons/ + + fonts: + - family: NotoSansKR + fonts: + - asset: assets/fonts/NotoSansKR-Regular.ttf + weight: 400 + - asset: assets/fonts/NotoSansKR-Medium.ttf + weight: 500 + - asset: assets/fonts/NotoSansKR-SemiBold.ttf + weight: 600 + - asset: assets/fonts/NotoSansKR-Bold.ttf + weight: 700 + - family: Pretendard + fonts: + - asset: assets/fonts/Pretendard-Thin.otf + weight: 100 + - asset: assets/fonts/Pretendard-ExtraLight.otf + weight: 200 + - asset: assets/fonts/Pretendard-Light.otf + weight: 300 + - asset: assets/fonts/Pretendard-Regular.otf + weight: 400 + - asset: assets/fonts/Pretendard-Medium.otf + weight: 500 + - asset: assets/fonts/Pretendard-SemiBold.otf + weight: 600 + - asset: assets/fonts/Pretendard-Bold.otf + weight: 700 + - asset: assets/fonts/Pretendard-ExtraBold.otf + weight: 800 + - asset: assets/fonts/Pretendard-Black.otf + weight: 900 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/models/concert_artist_test.dart b/test/models/concert_artist_test.dart new file mode 100644 index 0000000..e391d3c --- /dev/null +++ b/test/models/concert_artist_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/concert_artist.dart'; + +void main() { + group('ConcertArtist.fromJson은', () { + test('name과 선택 필드(imageUrl/introduction)를 파싱한다', () { + final artist = ConcertArtist.fromJson({ + 'artist': 'Taylor Swift', + 'imgUrl': 'https://img/a.jpg', + 'detail': '미국의 싱어송라이터', + }); + + expect(artist.name, 'Taylor Swift'); + expect(artist.imageUrl, 'https://img/a.jpg'); + expect(artist.introduction, '미국의 싱어송라이터'); + }); + + test('선택 필드가 없으면 null로 둔다', () { + final artist = ConcertArtist.fromJson({'artist': 'IU'}); + + expect(artist.imageUrl, isNull); + expect(artist.introduction, isNull); + }); + }); +} diff --git a/test/models/concert_comment_test.dart b/test/models/concert_comment_test.dart new file mode 100644 index 0000000..fbdefc0 --- /dev/null +++ b/test/models/concert_comment_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/concert_comment.dart'; + +void main() { + group('ConcertComment.fromJson은', () { + test('id/writer/content와 선택 필드를 파싱한다', () { + final comment = ConcertComment.fromJson({ + 'id': 7, + 'writer': '라이빗', + 'content': '기대돼요', + 'createdAt': '2026-05-23', + 'userId': 3, + }); + + expect(comment.id, 7); + expect(comment.writer, '라이빗'); + expect(comment.content, '기대돼요'); + expect(comment.createdAt, '2026-05-23'); + expect(comment.userId, 3); + }); + }); +} diff --git a/test/models/concert_test.dart b/test/models/concert_test.dart new file mode 100644 index 0000000..7d1ea5c --- /dev/null +++ b/test/models/concert_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/concert.dart'; + +void main() { + group('ConcertStatus.fromValue는', () { + test('서버 문자열을 enum으로 매핑하고 미지의 값은 unknown으로 둔다', () { + expect(ConcertStatus.fromValue('ONGOING'), ConcertStatus.ongoing); + expect(ConcertStatus.fromValue('UPCOMING'), ConcertStatus.upcoming); + expect(ConcertStatus.fromValue(null), ConcertStatus.unknown); + expect(ConcertStatus.fromValue('???'), ConcertStatus.unknown); + }); + }); + + group('Concert.fromJson은', () { + test('필수/선택 필드를 파싱하고 status를 enum으로 변환한다', () { + final concert = Concert.fromJson({ + 'id': 5, + 'title': 'Eras Tour', + 'artist': 'Taylor Swift', + 'status': 'UPCOMING', + 'poster': 'https://img/p.jpg', + 'startDate': '2026-06-01', + 'endDate': '2026-06-02', + 'venue': '고척돔', + 'daysLeft': 10, + }); + + expect(concert.id, 5); + expect(concert.title, 'Eras Tour'); + expect(concert.artist, 'Taylor Swift'); + expect(concert.status, ConcertStatus.upcoming); + expect(concert.posterUrl, 'https://img/p.jpg'); + expect(concert.venue, '고척돔'); + expect(concert.daysLeft, 10); + }); + }); +} diff --git a/test/models/nickname_test.dart b/test/models/nickname_test.dart new file mode 100644 index 0000000..7a9a529 --- /dev/null +++ b/test/models/nickname_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/nickname.dart'; + +void main() { + group('Nickname.isValid는', () { + test('영문/숫자/한글 1~10자를 유효한 닉네임으로 판정한다', () { + expect(Nickname.isValid('라이빗'), isTrue); + expect(Nickname.isValid('livith12'), isTrue); + expect(Nickname.isValid('가나다라마바사아자차'), isTrue); + }); + + test('빈 값, 10자 초과, 특수문자를 무효로 판정한다', () { + expect(Nickname.isValid(''), isFalse); + expect(Nickname.isValid('가나다라마바사아자차카'), isFalse); + expect(Nickname.isValid('hello!'), isFalse); + expect(Nickname.isValid('공백 포함'), isFalse); + }); + }); + + group('Nickname.tryParse는', () { + test('유효한 값이면 Nickname을, 무효한 값이면 null을 반환한다', () { + expect(Nickname.tryParse('라이빗')?.value, '라이빗'); + expect(Nickname.tryParse('bad nick!'), isNull); + }); + }); +} diff --git a/test/models/preference_models_test.dart b/test/models/preference_models_test.dart new file mode 100644 index 0000000..78422b5 --- /dev/null +++ b/test/models/preference_models_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/artist.dart'; +import 'package:livith/models/genre.dart'; + +void main() { + group('Genre.fromJson은', () { + test('id와 name을 파싱한다', () { + final genre = Genre.fromJson({'id': 1, 'name': 'POP'}); + + expect(genre.id, 1); + expect(genre.name, 'POP'); + }); + }); + + group('Artist.fromJson은', () { + test('id/name과 선택 필드(imageUrl/genreId)를 파싱한다', () { + final artist = Artist.fromJson({ + 'id': 10, + 'name': 'Taylor Swift', + 'imageUrl': 'https://img/x.jpg', + 'genreId': 4, + }); + + expect(artist.id, 10); + expect(artist.name, 'Taylor Swift'); + expect(artist.imageUrl, 'https://img/x.jpg'); + expect(artist.genreId, 4); + }); + + test('선택 필드가 없으면 null로 둔다', () { + final artist = Artist.fromJson({'id': 11, 'name': 'IU'}); + + expect(artist.imageUrl, isNull); + expect(artist.genreId, isNull); + }); + }); +} diff --git a/test/models/search_query_test.dart b/test/models/search_query_test.dart new file mode 100644 index 0000000..cdd3163 --- /dev/null +++ b/test/models/search_query_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/search_query.dart'; + +void main() { + group('SearchQuery 동등성은', () { + test('키워드와 장르 목록이 같으면 동등하고 hashCode가 같다', () { + const a = SearchQuery(keyword: 'taylor', genreNameList: ['JPOP', 'POP']); + const b = SearchQuery(keyword: 'taylor', genreNameList: ['JPOP', 'POP']); + + expect(a, b); + expect(a.hashCode, b.hashCode); + }); + + test('장르 목록이 다르면 동등하지 않다', () { + const a = SearchQuery(keyword: 'x', genreNameList: ['JPOP']); + const b = SearchQuery(keyword: 'x', genreNameList: ['POP']); + + expect(a == b, isFalse); + }); + }); + + group('SearchQuery.isEmpty는', () { + test('키워드와 장르가 모두 비면 true를 반환한다', () { + expect(const SearchQuery().isEmpty, isTrue); + expect(const SearchQuery(keyword: 'a').isEmpty, isFalse); + expect(const SearchQuery(genreNameList: ['JPOP']).isEmpty, isFalse); + }); + }); +} diff --git a/test/models/setlist_song_lyrics_test.dart b/test/models/setlist_song_lyrics_test.dart new file mode 100644 index 0000000..7137e17 --- /dev/null +++ b/test/models/setlist_song_lyrics_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/setlist.dart'; +import 'package:livith/models/song_lyrics.dart'; + +void main() { + group('SetlistSong.fromJson은', () { + test('id/title/order를 파싱한다', () { + final song = SetlistSong.fromJson({'id': 1, 'title': 'Love Story', 'order': 3}); + + expect(song.id, 1); + expect(song.title, 'Love Story'); + expect(song.order, 3); + }); + }); + + group('Setlist.fromJson은', () { + test('id/title/artist와 songs 목록을 파싱한다', () { + final setlist = Setlist.fromJson({ + 'id': 9, + 'title': 'Eras Setlist', + 'artist': 'Taylor Swift', + 'songs': [ + {'id': 1, 'title': 'A'}, + {'id': 2, 'title': 'B'}, + ], + }); + + expect(setlist.id, 9); + expect(setlist.title, 'Eras Setlist'); + expect(setlist.artist, 'Taylor Swift'); + expect(setlist.songList.length, 2); + expect(setlist.songList.first.title, 'A'); + }); + + test('songs가 없으면 빈 목록으로 둔다', () { + final setlist = Setlist.fromJson({'id': 1, 'title': 'X'}); + + expect(setlist.songList, isEmpty); + }); + }); + + group('SongLyrics.fromJson은', () { + test('가사/발음/번역 배열과 youtubeId를 파싱한다', () { + final lyrics = SongLyrics.fromJson({ + 'id': 5, + 'title': 'Song', + 'artist': 'Artist', + 'lyrics': ['l1', 'l2'], + 'pronunciation': ['p1'], + 'translation': ['t1', 't2'], + 'youtubeId': 'abc', + }); + + expect(lyrics.id, 5); + expect(lyrics.lyricList, ['l1', 'l2']); + expect(lyrics.pronunciationList, ['p1']); + expect(lyrics.translationList, ['t1', 't2']); + expect(lyrics.youtubeId, 'abc'); + }); + }); +} diff --git a/test/models/signup_info_test.dart b/test/models/signup_info_test.dart new file mode 100644 index 0000000..58a9885 --- /dev/null +++ b/test/models/signup_info_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/signup_info.dart'; +import 'package:livith/models/social_provider.dart'; + +void main() { + group('SignupInfo.toJson은', () { + test('서버 요청 키로 직렬화한다', () { + const info = SignupInfo( + provider: SocialProvider.kakao, + providerId: 'kakao-1', + email: 'a@b.com', + nickname: '라이빗', + isMarketingAgreed: true, + preferredGenreIdList: [1, 2], + preferredArtistIdList: [10], + ); + + final json = info.toJson(); + + expect(json['nickname'], '라이빗'); + expect(json['provider'], 'kakao'); + expect(json['providerId'], 'kakao-1'); + expect(json['email'], 'a@b.com'); + expect(json['marketingConsent'], isTrue); + expect(json['preferredGenreIds'], [1, 2]); + expect(json['preferredArtistIds'], [10]); + }); + }); +} diff --git a/test/models/user_test.dart b/test/models/user_test.dart new file mode 100644 index 0000000..92a172b --- /dev/null +++ b/test/models/user_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/login_status.dart'; +import 'package:livith/models/social_provider.dart'; +import 'package:livith/models/temp_user.dart'; +import 'package:livith/models/user.dart'; + +void main() { + group('User.fromJson은', () { + test('users/me 응답을 파싱하고 hasPreferredGenre를 hasPreferences로 매핑한다', () { + final json = { + 'id': 7, + 'provider': 'kakao', + 'providerId': 'kakao-123', + 'email': 'a@b.com', + 'nickname': '라이빗', + 'marketingConsent': true, + 'hasPreferredGenre': true, + }; + + final user = User.fromJson(json); + + expect(user.id, 7); + expect(user.provider, SocialProvider.kakao); + expect(user.providerId, 'kakao-123'); + expect(user.nickname, '라이빗'); + expect(user.hasPreferences, isTrue); + expect(user.marketingConsent, isTrue); + }); + }); + + group('TempUser.fromJson은', () { + test('provider 문자열과 providerId, email을 파싱한다', () { + final json = { + 'provider': 'apple', + 'providerId': 'apple-999', + 'email': null, + }; + + final tempUser = TempUser.fromJson(json); + + expect(tempUser.provider, SocialProvider.apple); + expect(tempUser.providerId, 'apple-999'); + expect(tempUser.email, isNull); + }); + }); + + group('LoginStatus.fromJson은', () { + test('isNewUser=false면 토큰을 가진 ExistingUser를 반환한다', () { + final json = { + 'isNewUser': false, + 'accessToken': 'access-1', + 'refreshToken': 'refresh-1', + }; + + final status = LoginStatus.fromJson(json); + + expect(status, isA()); + expect((status as ExistingUser).accessToken, 'access-1'); + expect(status.refreshToken, 'refresh-1'); + }); + + test('isNewUser=true면 tempUserData를 가진 NewUser를 반환한다', () { + final json = { + 'isNewUser': true, + 'tempUserData': { + 'provider': 'apple', + 'providerId': 'apple-1', + 'email': 'x@y.com', + }, + }; + + final status = LoginStatus.fromJson(json); + + expect(status, isA()); + expect((status as NewUser).tempUser.providerId, 'apple-1'); + }); + }); +} diff --git a/test/services/api_response_test.dart b/test/services/api_response_test.dart new file mode 100644 index 0000000..c545d2b --- /dev/null +++ b/test/services/api_response_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/services/api_response.dart'; + +void main() { + group('ApiResponse.fromJson은', () { + test('statusCode/message/data를 파싱하고 dataParser로 data를 변환한다', () { + // Arrange + final json = { + 'statusCode': 200, + 'error': null, + 'message': 'Success', + 'data': {'available': true}, + }; + + // Act + final response = ApiResponse.fromJson( + json, + (data) => (data as Map)['available'] as bool, + ); + + // Assert + expect(response.statusCode, 200); + expect(response.message, 'Success'); + expect(response.data, isTrue); + }); + + test('data가 null이면 dataParser를 호출하지 않고 data를 null로 둔다', () { + // Arrange + final json = { + 'statusCode': 200, + 'message': 'ok', + 'data': null, + }; + + // Act + final response = ApiResponse.fromJson( + json, + (_) => throw StateError('호출되면 안 됨'), + ); + + // Assert + expect(response.data, isNull); + }); + }); +} diff --git a/test/services/auth_interceptor_test.dart b/test/services/auth_interceptor_test.dart new file mode 100644 index 0000000..458059c --- /dev/null +++ b/test/services/auth_interceptor_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/services/auth_interceptor.dart'; +import 'package:livith/services/token_store.dart'; + +import 'package:dio/dio.dart'; + +void main() { + group('AuthInterceptor는', () { + test('저장된 액세스 토큰이 있으면 Authorization 헤더에 Bearer 토큰을 추가한다', () async { + // Arrange + final tokenStore = InMemoryTokenStore(); + await tokenStore.save(accessToken: 'access-123', refreshToken: 'refresh-456'); + final interceptor = AuthInterceptor(tokenStore); + final options = RequestOptions(path: '/concerts'); + + // Act + interceptor.onRequest(options, RequestInterceptorHandler()); + + // Assert + expect(options.headers['Authorization'], 'Bearer access-123'); + }); + + test('저장된 액세스 토큰이 없으면 Authorization 헤더를 추가하지 않는다', () { + // Arrange + final interceptor = AuthInterceptor(InMemoryTokenStore()); + final options = RequestOptions(path: '/concerts'); + + // Act + interceptor.onRequest(options, RequestInterceptorHandler()); + + // Assert + expect(options.headers.containsKey('Authorization'), isFalse); + }); + }); +} diff --git a/test/services/dio_failure_mapper_test.dart b/test/services/dio_failure_mapper_test.dart new file mode 100644 index 0000000..5872bd9 --- /dev/null +++ b/test/services/dio_failure_mapper_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/services/dio_failure_mapper.dart'; +import 'package:livith/services/failure.dart'; + +import 'package:dio/dio.dart'; + +void main() { + final options = RequestOptions(path: '/x'); + + group('mapDioException은', () { + test('401 응답을 AuthFailure로 매핑한다', () { + final exception = DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response(requestOptions: options, statusCode: 401), + ); + + final failure = mapDioException(exception); + + expect(failure, isA()); + }); + + test('500 응답을 상태코드를 보존한 ServerFailure로 매핑한다', () { + final exception = DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response(requestOptions: options, statusCode: 500), + ); + + final failure = mapDioException(exception); + + expect(failure, isA()); + expect((failure as ServerFailure).statusCode, 500); + }); + + test('연결 타임아웃을 NetworkFailure로 매핑한다', () { + final exception = DioException( + requestOptions: options, + type: DioExceptionType.connectionTimeout, + ); + + final failure = mapDioException(exception); + + expect(failure, isA()); + }); + }); +} diff --git a/test/services/token_refresh_policy_test.dart b/test/services/token_refresh_policy_test.dart new file mode 100644 index 0000000..143a8e3 --- /dev/null +++ b/test/services/token_refresh_policy_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/services/token_refresh_policy.dart'; + +void main() { + group('shouldAttemptRefresh는', () { + test('401이고 리프레시 토큰이 있으며 아직 재시도하지 않았으면 true를 반환한다', () { + final result = shouldAttemptRefresh( + statusCode: 401, + hasRefreshToken: true, + alreadyRetried: false, + ); + + expect(result, isTrue); + }); + + test('이미 재시도한 요청이면 false를 반환한다', () { + final result = shouldAttemptRefresh( + statusCode: 401, + hasRefreshToken: true, + alreadyRetried: true, + ); + + expect(result, isFalse); + }); + + test('리프레시 토큰이 없으면 false를 반환한다', () { + final result = shouldAttemptRefresh( + statusCode: 401, + hasRefreshToken: false, + alreadyRetried: false, + ); + + expect(result, isFalse); + }); + + test('401이 아니면 false를 반환한다', () { + final result = shouldAttemptRefresh( + statusCode: 500, + hasRefreshToken: true, + alreadyRetried: false, + ); + + expect(result, isFalse); + }); + }); +} diff --git a/test/view_models/auth_view_model_test.dart b/test/view_models/auth_view_model_test.dart new file mode 100644 index 0000000..923b45d --- /dev/null +++ b/test/view_models/auth_view_model_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/login_status.dart'; +import 'package:livith/models/signup_info.dart'; +import 'package:livith/models/social_provider.dart'; +import 'package:livith/models/temp_user.dart'; +import 'package:livith/providers/network_providers.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/services/auth_service.dart'; +import 'package:livith/services/social_auth_service.dart'; +import 'package:livith/services/token_store.dart'; +import 'package:livith/view_models/auth_view_model.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class _FakeAuthService implements AuthService { + _FakeAuthService(this.loginResult); + + final LoginStatus loginResult; + + @override + Future loginWithApple(String identityToken) async => loginResult; + + @override + Future loginWithKakao(String accessToken) async => loginResult; + + @override + Future signup(SignupInfo info) => throw UnimplementedError(); + + @override + Future isNicknameAvailable(String nickname) => throw UnimplementedError(); + + @override + Future logout(String refreshToken) async {} + + @override + Future withdraw(String reason) => throw UnimplementedError(); +} + +ProviderContainer _container({ + required LoginStatus loginResult, + required TokenStore tokenStore, +}) { + final container = ProviderContainer( + overrides: [ + authServiceProvider.overrideWithValue(_FakeAuthService(loginResult)), + socialAuthServiceProvider.overrideWithValue(const StubSocialAuthService()), + tokenStoreProvider.overrideWithValue(tokenStore), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + group('AuthViewModel.build는', () { + test('저장된 토큰이 없으면 Unauthenticated를 반환한다', () async { + final container = _container( + loginResult: const ExistingUser(accessToken: 'a', refreshToken: 'b'), + tokenStore: InMemoryTokenStore(), + ); + + final state = await container.read(authViewModelProvider.future); + + expect(state, isA()); + }); + + test('저장된 토큰이 있으면 Authenticated를 반환한다', () async { + final tokenStore = InMemoryTokenStore(); + await tokenStore.save(accessToken: 'a', refreshToken: 'b'); + final container = _container( + loginResult: const ExistingUser(accessToken: 'a', refreshToken: 'b'), + tokenStore: tokenStore, + ); + + final state = await container.read(authViewModelProvider.future); + + expect(state, isA()); + }); + }); + + group('AuthViewModel.loginWith는', () { + test('기존 사용자면 토큰을 저장하고 Authenticated가 된다', () async { + final tokenStore = InMemoryTokenStore(); + final container = _container( + loginResult: const ExistingUser(accessToken: 'acc', refreshToken: 'ref'), + tokenStore: tokenStore, + ); + await container.read(authViewModelProvider.future); + + await container.read(authViewModelProvider.notifier).loginWith(SocialProvider.kakao); + + expect(container.read(authViewModelProvider).value, isA()); + expect(tokenStore.accessToken, 'acc'); + }); + + test('신규 사용자면 OnboardingRequired가 된다', () async { + const tempUser = TempUser(provider: SocialProvider.apple, providerId: 'p-1'); + final container = _container( + loginResult: const NewUser(tempUser), + tokenStore: InMemoryTokenStore(), + ); + await container.read(authViewModelProvider.future); + + await container.read(authViewModelProvider.notifier).loginWith(SocialProvider.apple); + + final state = container.read(authViewModelProvider).value; + expect(state, isA()); + expect((state as OnboardingRequired).tempUser.providerId, 'p-1'); + }); + }); +} diff --git a/test/view_models/home_view_model_test.dart b/test/view_models/home_view_model_test.dart new file mode 100644 index 0000000..d4c62bb --- /dev/null +++ b/test/view_models/home_view_model_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/models/concert.dart'; +import 'package:livith/models/concert_artist.dart'; +import 'package:livith/providers/service_providers.dart'; +import 'package:livith/services/concert_service.dart'; +import 'package:livith/view_models/home_view_model.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class _FakeConcertService implements ConcertService { + _FakeConcertService({this.recommended = const [], this.interestThrows = false}); + + final List recommended; + final bool interestThrows; + + @override + Future> fetchRecommendedConcerts() async => recommended; + + @override + Future> fetchInterestConcerts() async { + if (interestThrows) throw Exception('unauthorized'); + return const []; + } + + @override + Future fetchConcert(int id) => throw UnimplementedError(); + + @override + Future fetchArtist(int concertId) => throw UnimplementedError(); +} + +Concert _concert(int id) => Concert( + id: id, + title: 'C$id', + artist: 'A', + status: ConcertStatus.upcoming, + ); + +void main() { + group('HomeViewModel.build는', () { + test('추천 콘서트를 로드해 상태에 담는다', () async { + final container = ProviderContainer( + overrides: [ + concertServiceProvider.overrideWithValue( + _FakeConcertService(recommended: [_concert(1), _concert(2)]), + ), + ], + ); + addTearDown(container.dispose); + + final state = await container.read(homeViewModelProvider.future); + + expect(state.recommendedConcertList.length, 2); + }); + + test('관심 콘서트 조회가 실패해도 빈 목록으로 처리하고 추천은 유지한다', () async { + final container = ProviderContainer( + overrides: [ + concertServiceProvider.overrideWithValue( + _FakeConcertService(recommended: [_concert(1)], interestThrows: true), + ), + ], + ); + addTearDown(container.dispose); + + final state = await container.read(homeViewModelProvider.future); + + expect(state.recommendedConcertList.length, 1); + expect(state.interestConcertList, isEmpty); + }); + }); +} diff --git a/test/view_models/onboarding_view_model_test.dart b/test/view_models/onboarding_view_model_test.dart new file mode 100644 index 0000000..a709af4 --- /dev/null +++ b/test/view_models/onboarding_view_model_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livith/view_models/onboarding_view_model.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +void main() { + ProviderContainer makeContainer() { + final container = ProviderContainer(); + addTearDown(container.dispose); + return container; + } + + OnboardingViewModel notifier(ProviderContainer container) => + container.read(onboardingViewModelProvider.notifier); + OnboardingState state(ProviderContainer container) => + container.read(onboardingViewModelProvider); + + group('OnboardingViewModel.setNickname은', () { + test('닉네임을 상태에 반영한다', () { + final container = makeContainer(); + + notifier(container).setNickname('라이빗'); + + expect(state(container).nickname, '라이빗'); + }); + }); + + group('OnboardingViewModel.toggleGenre는', () { + test('선택을 추가하고 다시 호출하면 제거한다', () { + final container = makeContainer(); + + notifier(container).toggleGenre(3); + expect(state(container).genreIdList, [3]); + + notifier(container).toggleGenre(3); + expect(state(container).genreIdList, isEmpty); + }); + }); + + group('OnboardingViewModel.toggleArtist는', () { + test('여러 아티스트 선택을 누적한다', () { + final container = makeContainer(); + + notifier(container) + ..toggleArtist(1) + ..toggleArtist(2); + + expect(state(container).artistIdList, [1, 2]); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 945393f..f9dfeb5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,24 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:livith/app.dart'; +import 'package:livith/providers/network_providers.dart'; +import 'package:livith/services/token_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { - testWidgets('홈 화면이 정상적으로 그려진다', (WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: App())); + testWidgets('미인증 상태로 진입하면 로그인 화면이 그려진다', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(InMemoryTokenStore()), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Livith'), findsOneWidget); - expect(find.text('Hello, Riverpod + MVVM!'), findsOneWidget); + expect(find.text('카카오로 시작하기'), findsOneWidget); + expect(find.text('Apple로 시작하기'), findsOneWidget); }); }