diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 80f79d0..eb3d68f 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -25,6 +25,10 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 | | Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 | | 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 | +| 화면 이동 | `lib/core/router/` | go_router `routerProvider` · 경로 상수 `AppRoutes` · 하단 탭 틀 `AppShell` | +| 테마·색 | `lib/core/theme/` | `AppColors` · `AppTheme` — 색·글자 크기를 화면에 새로 하드코딩하지 않는다 | +| 한/영 문구 | `lib/l10n/*.arb` | `context.l10n.키`. 생성물 `lib/l10n/generated/` 는 git 제외 — `flutter pub get` 이 만든다 | +| 한국어 줄바꿈 | `lib/core/utils/keep_all.dart` | 한국어 문단은 `keepAll()` 로 단어 단위 줄바꿈 (Flutter 기본은 글자 단위) | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): > - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))` diff --git a/flutter/.gitignore b/flutter/.gitignore index 3e8a293..abab6a5 100644 --- a/flutter/.gitignore +++ b/flutter/.gitignore @@ -51,4 +51,6 @@ app.*.map.json .env.production.local .env.local .env.development -.env.production \ No newline at end of file +.env.production +# 한/영 문구 생성물 — flutter pub get 이 lib/l10n/*.arb 로 다시 만든다 (l10n.yaml) +/lib/l10n/generated/ diff --git a/flutter/README.md b/flutter/README.md index 16d2584..df38640 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -1,17 +1,62 @@ -# tma_app +# WanderGo 앱 (Flutter) -A new Flutter project. +대구에서 시작하는 데이트·나들이 코스 앱. 화면 디자인은 디자인 캔버스(MVP 화면 20개)를 따른다. -## Getting Started +## 실행 -This project is a starting point for a Flutter application. +```bash +cd flutter +cp .env.example .env # 처음 한 번 — API_URL 을 개발 백엔드 주소로 +flutter pub get # 한/영 문구(lib/l10n/generated/)도 이때 만들어진다 +flutter run +``` -A few resources to get you started if this is your first Flutter project: +`API_URL` 은 백엔드 글로벌 prefix `/api` 까지 적는다. 포트는 루트 `.env.development` 의 `BACKEND_PORT`. -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +| 실행 환경 | API_URL 예 | +| --- | --- | +| Android 에뮬레이터 | `http://10.0.2.2:/api` | +| 실기기(같은 와이파이) | `http://:/api` | +| iOS 시뮬레이터 | `http://localhost:/api` | -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +- 평문 HTTP 는 **디버그 빌드에서만** 허용된다(`android/app/src/debug/AndroidManifest.xml`, iOS 는 로컬 네트워크만). +- 개발 백엔드는 루트에서 `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d`. + +## 검사 + +```bash +flutter analyze # Warning 0 +flutter test +``` + +CI(`.github/workflows/ci-flutter.yml`)는 `flutter/` 가 바뀐 커밋에서만 돈다. + +## 구조 + +``` +lib/ +├── main.dart · app.dart 앱 시작 · 테마/언어/화면 이동 연결 +├── core/ +│ ├── api/ Dio + 응답 언래핑·에러 코드 (템플릿) +│ ├── router/ go_router — 경로 상수(app_routes) · 하단 탭 틀(app_shell) +│ ├── theme/ 색(AppColors) · 테마(AppTheme) — 디자인 캔버스 값 +│ ├── l10n/ context.l10n · 앱 언어 선택 +│ ├── storage/ shared_preferences (민감하지 않은 설정만) +│ ├── icons/ 디자인 캔버스의 선 아이콘(SVG) +│ └── utils/ 순수 함수 — 거리·기간 표기, 주말 계산, 한국어 줄바꿈(keepAll) +├── features/ +│ ├── onboarding/ 첫 실행 · 위치 권한 +│ ├── location/ 기준 위치(현재 위치 / 대구 동네) +│ ├── home/ 홈 +│ └── event/ 축제·공연·행사·전시 목록·상세 +├── l10n/ app_ko.arb · app_en.arb (생성물 generated/ 는 git 제외) +└── shared/ 여러 화면이 쓰는 위젯·유틸 +``` + +## 규칙 메모 + +- 화면 문구는 `lib/l10n/*.arb` 에만 쓴다. 출시는 한국어·영어, 설계는 en/ja/zh-Hans/zh-Hant 까지 전제. +- 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위). +- 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다. +- 공공누리 Type3 사진은 자르지 않는다(`EventImage` 가 contain 으로 보인다). +- 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`. diff --git a/flutter/android/app/src/debug/AndroidManifest.xml b/flutter/android/app/src/debug/AndroidManifest.xml index 399f698..7763c40 100644 --- a/flutter/android/app/src/debug/AndroidManifest.xml +++ b/flutter/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index c4346bb..34d33fb 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + + + + + + + + + + diff --git a/flutter/assets/images/character.png b/flutter/assets/images/character.png new file mode 100644 index 0000000..6f0d2be Binary files /dev/null and b/flutter/assets/images/character.png differ diff --git a/flutter/assets/images/logo.png b/flutter/assets/images/logo.png new file mode 100644 index 0000000..5ef1d95 Binary files /dev/null and b/flutter/assets/images/logo.png differ diff --git a/flutter/assets/images/splash.png b/flutter/assets/images/splash.png new file mode 100644 index 0000000..43172bb Binary files /dev/null and b/flutter/assets/images/splash.png differ diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index a58c4ae..20f89c8 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -7,15 +7,20 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Tma App + WanderGo CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleLocalizations + + ko + en + CFBundleName - tma_app + WanderGo CFBundlePackageType APPL CFBundleShortVersionString @@ -26,6 +31,13 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocationWhenInUseUsageDescription + 가까운 장소와 축제·행사를 거리순으로 보여 드리는 데 씁니다. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/flutter/l10n.yaml b/flutter/l10n.yaml new file mode 100644 index 0000000..2737675 --- /dev/null +++ b/flutter/l10n.yaml @@ -0,0 +1,7 @@ +# 한/영 문구 생성 설정 — flutter pub get 이 lib/l10n/*.arb 로 lib/l10n/generated/ 를 만든다. +# 문구는 arb 만 고친다(생성 파일 직접 수정 금지). 출시는 ko·en, 설계는 en/ja/zh-Hans/zh-Hant 까지 전제. +arb-dir: lib/l10n +template-arb-file: app_ko.arb +output-localization-file: app_localizations.dart +output-dir: lib/l10n/generated +nullable-getter: false diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart new file mode 100644 index 0000000..6e26dfd --- /dev/null +++ b/flutter/lib/app.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/l10n/locale_provider.dart'; +import 'package:wandergo/core/router/app_router.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; + +/// 앱 뿌리 — 테마 · 언어 · 화면 이동 +class WanderGoApp extends ConsumerWidget { + const WanderGoApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp.router( + onGenerateTitle: (context) => context.l10n.appTitle, + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + // null 이면 기기 언어. 지원하지 않는 언어는 supportedLocales 의 첫 항목(영어) + locale: ref.watch(appLocaleProvider), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + routerConfig: ref.watch(routerProvider), + ); + } +} diff --git a/flutter/lib/core/clock.dart b/flutter/lib/core/clock.dart new file mode 100644 index 0000000..169b4d5 --- /dev/null +++ b/flutter/lib/core/clock.dart @@ -0,0 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 지금 시각 — 테스트에서 날짜를 고정하려고 주입한다 +final clockProvider = Provider((ref) => DateTime.now); diff --git a/flutter/lib/core/icons/app_icons.dart b/flutter/lib/core/icons/app_icons.dart new file mode 100644 index 0000000..98bec59 --- /dev/null +++ b/flutter/lib/core/icons/app_icons.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// 디자인 캔버스의 선 아이콘(24 그리드). +/// +/// 규칙: 조작 아이콘은 선 1.75(16px 이하는 2), 선택된 상태만 같은 색 16% 채움. +/// 색은 [AppIcon.color] 로 정한다(SVG 의 currentColor). +enum AppIconData { + home( + '', + ), + map( + '', + ), + route( + '', + ), + person( + '', + ), + globe( + '', + ), + pin( + '', + ), + chevronDown(''), + chevronRight(''); + + const AppIconData(this.body); + + /// `{fill}` 자리에 선택 상태의 채움이 들어간다 + final String body; +} + +class AppIcon extends StatelessWidget { + const AppIcon( + this.icon, { + super.key, + this.size = 24, + required this.color, + this.filled = false, + this.strokeWidth, + }); + + final AppIconData icon; + final double size; + final Color color; + + /// 선택된 상태 — 주 형태를 같은 색 16% 로 채운다 + final bool filled; + + /// 생략하면 크기에 따라 1.75(16px 초과) 또는 2 + final double? strokeWidth; + + @override + Widget build(BuildContext context) { + final stroke = strokeWidth ?? (size <= 16 ? 2 : 1.75); + final fill = filled ? ' fill="currentColor" fill-opacity=".16"' : ''; + final svg = + '${icon.body.replaceAll('{fill}', fill)}'; + return SvgPicture.string( + svg, + width: size, + height: size, + theme: SvgTheme(currentColor: color), + excludeFromSemantics: true, + ); + } +} diff --git a/flutter/lib/core/l10n/l10n.dart b/flutter/lib/core/l10n/l10n.dart new file mode 100644 index 0000000..849e8b9 --- /dev/null +++ b/flutter/lib/core/l10n/l10n.dart @@ -0,0 +1,10 @@ +import 'package:flutter/widgets.dart'; + +import 'package:wandergo/l10n/generated/app_localizations.dart'; + +export 'package:wandergo/l10n/generated/app_localizations.dart'; + +/// `context.l10n.homeTitle` 처럼 짧게 쓰기 위한 확장 +extension L10nContext on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/flutter/lib/core/l10n/locale_provider.dart b/flutter/lib/core/l10n/locale_provider.dart new file mode 100644 index 0000000..c78b1dc --- /dev/null +++ b/flutter/lib/core/l10n/locale_provider.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/storage/preferences.dart'; + +/// 사용자가 고른 앱 언어. null 이면 기기 언어를 따른다 +/// (지원하지 않는 기기 언어는 영어로 보인다 — AppLocalizations.supportedLocales 의 첫 항목). +final appLocaleProvider = NotifierProvider( + AppLocaleNotifier.new, +); + +class AppLocaleNotifier extends Notifier { + @override + Locale? build() { + final code = ref + .watch(sharedPreferencesProvider) + .getString(PrefKeys.locale); + return code == null ? null : Locale(code); + } + + Future select(Locale locale) async { + state = locale; + await ref + .read(sharedPreferencesProvider) + .setString(PrefKeys.locale, locale.languageCode); + } +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart new file mode 100644 index 0000000..e82ed3e --- /dev/null +++ b/flutter/lib/core/router/app_router.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/router/app_shell.dart'; +import 'package:wandergo/features/event/presentation/pages/event_detail_page.dart'; +import 'package:wandergo/features/event/presentation/pages/event_list_page.dart'; +import 'package:wandergo/features/home/presentation/pages/home_page.dart'; +import 'package:wandergo/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:wandergo/features/onboarding/presentation/providers/onboarding_provider.dart'; +import 'package:wandergo/shared/widgets/coming_soon_page.dart'; + +/// 화면 이동 — 첫 실행을 마치기 전에는 어느 경로든 첫 실행 화면으로 보낸다. +/// 탭(홈·지도·내 코스·내 정보)은 각자 이동 기록을 따로 가진다. +final routerProvider = Provider((ref) { + final rootKey = GlobalKey(); + final router = GoRouter( + navigatorKey: rootKey, + initialLocation: AppRoutes.home, + redirect: (context, state) { + final done = ref.read(onboardingDoneProvider); + final atOnboarding = state.matchedLocation == AppRoutes.onboarding; + if (!done && !atOnboarding) return AppRoutes.onboarding; + if (done && atOnboarding) return AppRoutes.home; + return null; + }, + routes: [ + GoRoute( + path: AppRoutes.onboarding, + builder: (context, state) => const OnboardingPage(), + ), + StatefulShellRoute.indexedStack( + builder: (context, state, shell) => AppShell(navigationShell: shell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const HomePage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.map, + builder: (context, state) => + ComingSoonPage(title: context.l10n.tabMap), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.courses, + builder: (context, state) => + ComingSoonPage(title: context.l10n.tabCourses), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.me, + builder: (context, state) => + ComingSoonPage(title: context.l10n.tabMe), + ), + ], + ), + ], + ), + // 탭 바깥(하단 메뉴를 가린다) + GoRoute( + path: AppRoutes.events, + builder: (context, state) { + final query = state.uri.queryParameters; + return EventListPage( + range: (from: query['from'] ?? '', to: query['to'] ?? ''), + isUpcoming: query['upcoming'] == '1', + ); + }, + ), + GoRoute( + path: AppRoutes.eventDetailPattern, + builder: (context, state) => + EventDetailPage(id: state.pathParameters['id']!), + ), + ], + ); + ref.onDispose(router.dispose); + return router; +}); diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart new file mode 100644 index 0000000..4f71464 --- /dev/null +++ b/flutter/lib/core/router/app_routes.dart @@ -0,0 +1,25 @@ +import 'package:wandergo/core/utils/weekend.dart'; + +/// 화면 경로 — 앱 전체에서 이 상수만 쓴다 +abstract final class AppRoutes { + static const String onboarding = '/onboarding'; + static const String home = '/'; + static const String map = '/map'; + static const String courses = '/courses'; + static const String me = '/me'; + + /// 행사 목록 — ?from=&to=&upcoming=1 + static const String events = '/events'; + static const String eventDetailPattern = '/events/:id'; + + static String eventDetail(String id) => '/events/$id'; + + static String eventList(DateRange range, {required bool isUpcoming}) => Uri( + path: events, + queryParameters: { + 'from': range.from, + 'to': range.to, + if (isUpcoming) 'upcoming': '1', + }, + ).toString(); +} diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart new file mode 100644 index 0000000..ced0544 --- /dev/null +++ b/flutter/lib/core/router/app_shell.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; + +/// 하단 메뉴가 있는 탭 화면 틀 — 홈 · 지도 · 내 코스 · 내 정보 +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final tabs = [ + (icon: AppIconData.home, label: l10n.tabHome), + (icon: AppIconData.map, label: l10n.tabMap), + (icon: AppIconData.route, label: l10n.tabCourses), + (icon: AppIconData.person, label: l10n.tabMe), + ]; + return Scaffold( + body: navigationShell, + bottomNavigationBar: DecoratedBox( + decoration: const BoxDecoration( + color: AppColors.background, + border: Border(top: BorderSide(color: AppColors.divider)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + for (var i = 0; i < tabs.length; i++) + _TabItem( + icon: tabs[i].icon, + label: tabs[i].label, + selected: i == navigationShell.currentIndex, + // 지금 탭을 다시 누르면 그 탭의 첫 화면으로 돌아간다 + onTap: () => navigationShell.goBranch( + i, + initialLocation: i == navigationShell.currentIndex, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TabItem extends StatelessWidget { + const _TabItem({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final AppIconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = selected ? AppColors.brand : AppColors.textTertiary; + return Semantics( + button: true, + selected: selected, + label: label, + excludeSemantics: true, + child: InkResponse( + onTap: onTap, + radius: 36, + child: SizedBox( + width: 64, + height: 56, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon(icon, color: color, filled: selected), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + height: 16 / 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: color, + ), + maxLines: 1, + overflow: TextOverflow.visible, + softWrap: false, + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/core/storage/preferences.dart b/flutter/lib/core/storage/preferences.dart new file mode 100644 index 0000000..83a3af0 --- /dev/null +++ b/flutter/lib/core/storage/preferences.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 앱 설정 저장소 — 민감하지 않은 값(언어·첫 실행 여부·기준 동네)만 둔다. +/// 토큰은 여기 두지 않는다(flutter_secure_storage 사용). +/// +/// main 에서 [SharedPreferences.getInstance] 결과로 덮어쓴다. 테스트도 같은 방식으로 주입한다. +final sharedPreferencesProvider = Provider( + (ref) => + throw UnimplementedError('main 에서 sharedPreferencesProvider 를 덮어써야 한다'), +); + +/// 저장 키 — 앱 전체에서 이 상수만 쓴다 +abstract final class PrefKeys { + /// 고른 언어 코드('ko'·'en'). 없으면 기기 언어를 따른다 + static const String locale = 'app_locale'; + + /// 첫 실행 화면을 마쳤는지 + static const String onboardingDone = 'onboarding_done'; + + /// 기준 위치('current' 또는 동네 이름). 현재 위치 좌표 자체는 저장하지 않는다 + static const String baseLocation = 'base_location'; +} diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..42bf366 --- /dev/null +++ b/flutter/lib/core/theme/app_colors.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// WanderGo 색 — 디자인 캔버스(2026-10-29 v30)의 값을 그대로 옮겼다. +/// +/// 브랜드색은 [brand] 하나만 쓴다. 더 진한 대체 빨강을 따로 만들지 않는다. +/// 연한 브랜드 배경([brandSoft]) 위 글자는 브랜드색 대신 [text] 를 쓴다. +abstract final class AppColors { + /// 브랜드 — 버튼·선택된 탭·강조 글자 + static const Color brand = Color(0xFFFF5A60); + + /// 연한 브랜드 배경 (아이콘 타일) + static const Color brandSoft = Color(0xFFFFEDEE); + + /// 스플래시 그라데이션 시작색 (끝은 [brand]) + static const Color splashStart = Color(0xFFFF1F1F); + + /// 화면 배경 — 카드 대신 흰 바탕 위 평면 목록으로 구성한다 + static const Color background = Color(0xFFFFFFFF); + + /// 본문 글자 + static const Color text = Color(0xFF161A23); + + /// 진한 보조 글자 (설명 문단·기준 위치) + static const Color textSecondary = Color(0xFF3D4250); + + /// 보조 글자 (메타 정보·비선택 탭) + static const Color textTertiary = Color(0xFF5B6170); + + /// 입력창 안내 글자·꼬리말 + static const Color textHint = Color(0xFF6B7180); + + /// 버튼 테두리 + static const Color border = Color(0xFFE3E6EE); + + /// 구분선 + static const Color divider = Color(0xFFEEF0F4); + + /// 입력창 채움 + static const Color inputFill = Color(0xFFF3F4F7); + + /// 사진이 없을 때의 자리 표시 + static const Color placeholder = Color(0xFFF5F6FA); + + /// 축제·행사 날짜 글자 + static const Color eventDate = Color(0xFF8A5A00); + + /// 반려동물 동반 글자(발바닥 아이콘과 함께) + static const Color pet = Color(0xFF0B6157); +} diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..e39be74 --- /dev/null +++ b/flutter/lib/core/theme/app_theme.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/theme/app_colors.dart'; + +/// 버튼·입력창·사진 모서리 반경 +const double kRadius = 12; + +/// 화면 좌우 여백 +const double kPagePadding = 20; + +/// 앱 테마 — 흰 바탕, 그림자 없음, 브랜드색 하나. +/// +/// 글꼴은 Pretendard 를 쓰기로 했다. 글꼴 파일을 받기 전까지는 기기 기본 글꼴로 보인다 +/// ([kFontFamily] 가 null). +abstract final class AppTheme { + /// Pretendard 를 pubspec fonts 에 등록하면 'Pretendard' 로 바꾼다. + static const String? kFontFamily = null; + + static ThemeData light() { + const colorScheme = ColorScheme.light( + primary: AppColors.brand, + onPrimary: Colors.white, + secondary: AppColors.brand, + onSecondary: Colors.white, + surface: AppColors.background, + onSurface: AppColors.text, + onSurfaceVariant: AppColors.textTertiary, + outline: AppColors.border, + outlineVariant: AppColors.divider, + ); + + // 글자 스타일이 들어가는 곳(상단 제목·버튼)은 base 의 textTheme 에서 파생한다. + // 따로 TextStyle 을 만들면 글꼴 이름이 빠져 테마 글꼴과 어긋난다. + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + fontFamily: kFontFamily, + scaffoldBackgroundColor: AppColors.background, + textTheme: _textTheme, + dividerTheme: const DividerThemeData( + color: AppColors.divider, + thickness: 1, + space: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.inputFill, + hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 15), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(kRadius), + borderSide: BorderSide.none, + ), + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + showDragHandle: true, + ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), + ); + final text = base.textTheme; + final buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kRadius), + ); + + return base.copyWith( + appBarTheme: AppBarTheme( + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: text.titleSmall?.copyWith( + fontSize: 17, + height: 24 / 17, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.brand, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: buttonShape, + textStyle: text.labelLarge?.copyWith(fontSize: 15), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.text, + minimumSize: const Size(44, 44), + side: const BorderSide(color: AppColors.border), + shape: buttonShape, + textStyle: text.labelLarge?.copyWith(fontSize: 14), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.textTertiary, + minimumSize: const Size(44, 44), + textStyle: text.labelLarge?.copyWith(fontSize: 14), + ), + ), + ); + } + + /// 디자인 캔버스의 글자 크기·줄 높이 + static const TextTheme _textTheme = TextTheme( + // 첫 실행 제목 26/36 + headlineSmall: TextStyle( + fontSize: 26, + height: 36 / 26, + fontWeight: FontWeight.w700, + color: AppColors.text, + ), + // 홈 제목·상세 제목 22/30 + titleLarge: TextStyle( + fontSize: 22, + height: 30 / 22, + fontWeight: FontWeight.w700, + color: AppColors.text, + ), + // 섹션 제목 18/26 + titleMedium: TextStyle( + fontSize: 18, + height: 26 / 18, + fontWeight: FontWeight.w700, + color: AppColors.text, + ), + // 목록 이름 15/21 + titleSmall: TextStyle( + fontSize: 15, + height: 21 / 15, + fontWeight: FontWeight.w600, + color: AppColors.text, + ), + // 설명 문단 15/23 + bodyLarge: TextStyle( + fontSize: 15, + height: 23 / 15, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + // 보조 설명 13/19 + bodyMedium: TextStyle( + fontSize: 13, + height: 19 / 13, + fontWeight: FontWeight.w400, + color: AppColors.textTertiary, + ), + // 메타 정보 12/16 + bodySmall: TextStyle( + fontSize: 12, + height: 16 / 12, + fontWeight: FontWeight.w400, + color: AppColors.textTertiary, + ), + // 버튼·칩 글자 13 + labelLarge: TextStyle( + fontSize: 13, + height: 18 / 13, + fontWeight: FontWeight.w600, + color: AppColors.text, + ), + ); +} diff --git a/flutter/lib/core/utils/format.dart b/flutter/lib/core/utils/format.dart new file mode 100644 index 0000000..355e5aa --- /dev/null +++ b/flutter/lib/core/utils/format.dart @@ -0,0 +1,100 @@ +import 'package:intl/intl.dart'; + +const int _metersPerKm = 1000; + +/// km 를 소수 첫째 자리까지 보이는 상한 — 이보다 멀면 정수 km 로 보인다 +const int _decimalKmLimitM = 100 * _metersPerKm; + +/// 거리 표시 — 1km 미만은 m, 100km 미만은 소수 한 자리 km, 그보다 멀면 정수 km. +/// 예: 850 → "850m", 1240 → "1.2km", 123456 → "123km" +String formatDistance(num meters) { + final rounded = meters.round(); + if (rounded < _metersPerKm) return '${rounded}m'; + final km = meters / _metersPerKm; + if (meters < _decimalKmLimitM) return '${km.toStringAsFixed(1)}km'; + return '${km.round()}km'; +} + +/// 'YYYY-MM-DD' → 날짜(UTC 자정). 형식이 틀리면 null. +DateTime? parseYmd(String value) { + final m = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(value); + if (m == null) return null; + return DateTime.utc(int.parse(m[1]!), int.parse(m[2]!), int.parse(m[3]!)); +} + +/// 날짜 → 'YYYY-MM-DD' +String toYmd(DateTime date) { + final mm = date.month.toString().padLeft(2, '0'); + final dd = date.day.toString().padLeft(2, '0'); + return '${date.year}-$mm-$dd'; +} + +/// 행사 기간 표시 — 한국어는 "10.31–11.2", 하루짜리는 "11.8". +/// 다른 언어는 그 언어의 월·일 표기를 쓴다(영어 "Oct 31 – Nov 2"). +String formatDateRange(String start, String end, {String localeName = 'ko'}) { + final s = parseYmd(start); + final e = parseYmd(end); + if (s == null || e == null) return start == end ? start : '$start–$end'; + + final String Function(DateTime) one; + final String separator; + if (localeName.startsWith('ko')) { + one = (d) => '${d.month}.${d.day}'; + separator = '–'; + } else { + final format = DateFormat.MMMd(localeName); + one = format.format; + separator = ' – '; + } + return s == e ? one(s) : '${one(s)}$separator${one(e)}'; +} + +/// 시·도 이름(긴 이름과 줄임말) +const Set _sidoNames = { + '서울특별시', + '부산광역시', + '대구광역시', + '인천광역시', + '광주광역시', + '대전광역시', + '울산광역시', + '세종특별자치시', + '경기도', + '강원특별자치도', + '강원도', + '충청북도', + '충청남도', + '전북특별자치도', + '전라북도', + '전라남도', + '경상북도', + '경상남도', + '제주특별자치도', + '서울', + '부산', + '대구', + '인천', + '광주', + '대전', + '울산', + '세종', + '경기', + '강원', + '충북', + '충남', + '전북', + '전남', + '경북', + '경남', + '제주', +}; + +/// 주소에서 목록에 보일 짧은 지역명을 꺼낸다 — 시·도 다음 단어(구·군·시). +/// 예: "대구광역시 수성구 두산동 …" → "수성구", "경상북도 포항시 북구 …" → "포항시". +/// 시·도로 시작하지 않거나 다음 단어가 없으면 null. +String? shortRegion(String? address) { + if (address == null) return null; + final parts = address.trim().split(RegExp(r'\s+')); + if (parts.length < 2 || !_sidoNames.contains(parts[0])) return null; + return parts[1]; +} diff --git a/flutter/lib/core/utils/keep_all.dart b/flutter/lib/core/utils/keep_all.dart new file mode 100644 index 0000000..a66b43f --- /dev/null +++ b/flutter/lib/core/utils/keep_all.dart @@ -0,0 +1,17 @@ +/// 줄바꿈을 막는 보이지 않는 글자(U+2060 WORD JOINER) +const String _wordJoiner = '⁠'; + +final RegExp _whitespace = RegExp(r'\s+'); + +/// 한국어 문단을 단어 단위로만 줄바꿈되게 한다 (CSS `word-break: keep-all` 과 같은 효과). +/// +/// Flutter 의 기본 줄바꿈은 한글을 글자 단위로 끊어 "같이 짜 볼까\n요?" 처럼 보인다. +/// 단어 안의 글자 사이에 줄바꿈 금지 글자를 넣어, 공백에서만 줄이 바뀌게 한다. +/// 공백·줄바꿈 문자는 그대로 둔다. 화면 표시용으로만 쓴다(저장·비교에 쓰지 않는다). +String keepAll(String text) { + return text.splitMapJoin( + _whitespace, + onMatch: (m) => m[0]!, + onNonMatch: (word) => word.runes.map(String.fromCharCode).join(_wordJoiner), + ); +} diff --git a/flutter/lib/core/utils/weekend.dart b/flutter/lib/core/utils/weekend.dart new file mode 100644 index 0000000..e12d16e --- /dev/null +++ b/flutter/lib/core/utils/weekend.dart @@ -0,0 +1,33 @@ +import 'package:wandergo/core/utils/format.dart'; + +/// 한국 표준시(UTC+9) — 기기 시간대와 상관없이 한국 날짜로 계산한다(백엔드와 같은 기준) +const Duration _kstOffset = Duration(hours: 9); + +/// 지금의 한국 날짜(시각은 버림). 기기의 일광 절약 시간에 흔들리지 않게 UTC 자정으로 둔다. +DateTime kstToday(DateTime now) { + final kst = now.toUtc().add(_kstOffset); + return DateTime.utc(kst.year, kst.month, kst.day); +} + +/// 기간 — 'YYYY-MM-DD' 두 개 +typedef DateRange = ({String from, String to}); + +/// '이번 주말' 기간 — 평일이면 다가오는 토·일, 토요일이면 오늘·내일, 일요일이면 오늘 하루. +DateRange weekendRange(DateTime now) { + final today = kstToday(now); + final DateTime from; + if (today.weekday == DateTime.sunday) { + from = today; + } else { + from = today.add(Duration(days: DateTime.saturday - today.weekday)); + } + final daysToSunday = DateTime.sunday - from.weekday; + final to = from.add(Duration(days: daysToSunday)); + return (from: toYmd(from), to: toYmd(to)); +} + +/// 오늘부터 [days]일 동안 (오늘 포함) +DateRange upcomingRange(DateTime now, {required int days}) { + final today = kstToday(now); + return (from: toYmd(today), to: toYmd(today.add(Duration(days: days - 1)))); +} diff --git a/flutter/lib/features/event/data/models/event_models.dart b/flutter/lib/features/event/data/models/event_models.dart new file mode 100644 index 0000000..f09b737 --- /dev/null +++ b/flutter/lib/features/event/data/models/event_models.dart @@ -0,0 +1,177 @@ +/// 축제·공연·행사·전시 — 백엔드 EventListItemDto / EventDetailDto 와 1:1 대응한다. +library; + +/// 행사 분류 (백엔드 EVENT_KINDS) +enum EventKind { + festival, + performance, + event, + exhibition; + + /// 모르는 값이면 null — 백엔드가 분류를 늘려도 앱이 죽지 않게 한다 + static EventKind? parse(Object? value) { + for (final kind in values) { + if (kind.name == value) return kind; + } + return null; + } +} + +/// 사진 공공누리 유형 — Type3 은 변경 금지(자르지 않고 그대로 보인다) +enum ImageLicense { + type1('Type1'), + type3('Type3'); + + const ImageLicense(this.code); + + final String code; + + static ImageLicense? parse(Object? value) { + for (final license in values) { + if (license.code == value) return license; + } + return null; + } +} + +/// 목록의 한 건 +class EventItem { + const EventItem({ + required this.id, + required this.name, + required this.kind, + required this.startDate, + required this.endDate, + required this.ongoing, + this.genre, + this.address, + this.eventPlace, + this.lat, + this.lng, + this.distanceM, + this.feeText, + this.imageThumbUrl, + this.imageLicense, + }); + + final String id; + final String name; + final EventKind? kind; + + /// 'YYYY-MM-DD' + final String startDate; + final String endDate; + + /// 오늘(한국 날짜) 진행 중인지 + final bool ongoing; + final String? genre; + final String? address; + final String? eventPlace; + final double? lat; + final double? lng; + + /// 기준 좌표에서의 거리(m). 좌표 없이 조회하면 null + final double? distanceM; + final String? feeText; + final String? imageThumbUrl; + final ImageLicense? imageLicense; + + factory EventItem.fromJson(Map json) { + return EventItem( + id: json['id'] as String, + name: json['name'] as String, + kind: EventKind.parse(json['kind']), + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + ongoing: json['ongoing'] as bool? ?? false, + genre: json['genre'] as String?, + address: json['address'] as String?, + eventPlace: json['eventPlace'] as String?, + lat: (json['lat'] as num?)?.toDouble(), + lng: (json['lng'] as num?)?.toDouble(), + distanceM: (json['distanceM'] as num?)?.toDouble(), + feeText: json['feeText'] as String?, + imageThumbUrl: json['imageThumbUrl'] as String?, + imageLicense: ImageLicense.parse(json['imageLicense']), + ); + } +} + +/// 정보 출처 표기 — 화면에 반드시 보인다 +class Attribution { + const Attribution({required this.provider, this.asOf}); + + final String provider; + + /// 원본 기준 시점 + final String? asOf; + + factory Attribution.fromJson(Map json) { + return Attribution( + provider: json['provider'] as String, + asOf: json['asOf'] as String?, + ); + } +} + +/// 상세 — 목록 필드에 소개·시간·링크·출처를 더한다 +class EventDetail { + const EventDetail({ + required this.item, + required this.attribution, + this.overview, + this.playtimeText, + this.homepage, + this.phone, + this.bookingUrl, + this.imageUrl, + }); + + final EventItem item; + final Attribution attribution; + final String? overview; + final String? playtimeText; + final String? homepage; + final String? phone; + final String? bookingUrl; + final String? imageUrl; + + factory EventDetail.fromJson(Map json) { + return EventDetail( + item: EventItem.fromJson(json), + attribution: Attribution.fromJson( + json['attribution'] as Map, + ), + overview: json['overview'] as String?, + playtimeText: json['playtimeText'] as String?, + homepage: json['homepage'] as String?, + phone: json['phone'] as String?, + bookingUrl: json['bookingUrl'] as String?, + imageUrl: json['imageUrl'] as String?, + ); + } +} + +/// 커서 페이지 — 응답 data 의 {items, pageInfo} +class EventPage { + const EventPage({ + required this.items, + required this.hasNext, + this.nextCursor, + }); + + final List items; + final bool hasNext; + final String? nextCursor; + + factory EventPage.fromJson(Map json) { + final pageInfo = json['pageInfo'] as Map; + return EventPage( + items: (json['items'] as List) + .map((e) => EventItem.fromJson(e as Map)) + .toList(), + hasNext: pageInfo['hasNext'] as bool? ?? false, + nextCursor: pageInfo['nextCursor'] as String?, + ); + } +} diff --git a/flutter/lib/features/event/data/repositories/event_repository.dart b/flutter/lib/features/event/data/repositories/event_repository.dart new file mode 100644 index 0000000..573ce14 --- /dev/null +++ b/flutter/lib/features/event/data/repositories/event_repository.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/api/dio_provider.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; + +/// 축제·공연·행사 API — `GET /events`, `GET /events/{id}`. +/// +/// 응답 언래핑과 에러 코드 매핑은 dio 인터셉터가 맡는다. 여기서는 파싱만 한다. +class EventRepository { + const EventRepository(this._dio); + + final Dio _dio; + + /// 기간과 겹치는 행사. [near] 를 주면 [radiusM] 안을 거리순으로 준다. + Future search({ + required DateRange range, + GeoPoint? near, + int? radiusM, + int limit = 20, + String? cursor, + }) async { + final response = await _dio.get>( + '/events', + queryParameters: { + 'from': range.from, + 'to': range.to, + 'limit': limit, + 'lat': ?near?.lat, + 'lng': ?near?.lng, + 'radius': ?radiusM, + 'cursor': ?cursor, + }, + ); + return EventPage.fromJson(response.data!); + } + + Future getEvent(String id) async { + final response = await _dio.get>('/events/$id'); + return EventDetail.fromJson(response.data!); + } +} + +final eventRepositoryProvider = Provider( + (ref) => EventRepository(ref.watch(dioProvider)), +); diff --git a/flutter/lib/features/event/presentation/pages/event_detail_page.dart b/flutter/lib/features/event/presentation/pages/event_detail_page.dart new file mode 100644 index 0000000..cefdd43 --- /dev/null +++ b/flutter/lib/features/event/presentation/pages/event_detail_page.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; +import 'package:wandergo/features/event/presentation/widgets/event_visuals.dart'; +import 'package:wandergo/shared/utils/open_link.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 상세 사진 높이 — 포스터(세로)도 자르지 않고 통째로 보인다 +const double _kImageHeight = 280; + +/// 축제·공연·행사·전시 상세 +class EventDetailPage extends ConsumerWidget { + const EventDetailPage({super.key, required this.id}); + + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detail = ref.watch(eventDetailProvider(id)); + return Scaffold( + appBar: AppBar(), + body: switch (detail) { + AsyncData(:final value) => _DetailBody(detail: value), + AsyncError(:final error) => Padding( + padding: const EdgeInsets.all(kPagePadding), + child: LoadError( + error: error, + onRetry: () => ref.invalidate(eventDetailProvider(id)), + ), + ), + _ => const Center(child: CircularProgressIndicator()), + }, + ); + } +} + +class _DetailBody extends StatelessWidget { + const _DetailBody({required this.detail}); + + final EventDetail detail; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final item = detail.item; + final imageUrl = detail.imageUrl ?? item.imageThumbUrl; + final kindLine = [ + ?item.kind?.label(l10n), + if (item.genre != null && item.genre != item.kind?.label(l10n)) + item.genre!, + ].join(' · '); + final dates = formatDateRange( + item.startDate, + item.endDate, + localeName: l10n.localeName, + ); + final place = [?item.eventPlace, ?item.address].join('\n'); + final attribution = detail.attribution.asOf == null + ? l10n.sourceLabel(detail.attribution.provider) + : l10n.sourceAsOf( + detail.attribution.provider, + detail.attribution.asOf!, + ); + + return ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + if (imageUrl != null) + EventImage( + url: imageUrl, + kind: item.kind, + license: item.imageLicense, + height: _kImageHeight, + radius: 0, + fit: BoxFit.contain, + ), + Padding( + padding: const EdgeInsets.fromLTRB(kPagePadding, 20, kPagePadding, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (kindLine.isNotEmpty) + Text( + kindLine, + style: textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text(keepAll(item.name), style: textTheme.titleLarge), + const SizedBox(height: 16), + _InfoRow( + icon: Icons.calendar_today_outlined, + text: dates, + trailing: item.ongoing ? l10n.eventOngoing : null, + ), + if (place.isNotEmpty) + _InfoRow(icon: Icons.place_outlined, text: place), + if (detail.playtimeText != null) + _InfoRow(icon: Icons.schedule, text: detail.playtimeText!), + if (item.feeText != null) + _InfoRow( + icon: Icons.confirmation_number_outlined, + text: item.feeText!, + ), + const SizedBox(height: 16), + _Actions(detail: detail), + if (detail.overview != null) ...[ + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 24), + Text(l10n.eventAbout, style: textTheme.titleMedium), + const SizedBox(height: 8), + Text(keepAll(detail.overview!), style: textTheme.bodyLarge), + ], + const SizedBox(height: 24), + Text( + attribution, + style: textTheme.bodySmall?.copyWith(color: AppColors.textHint), + ), + ], + ), + ), + ], + ); + } +} + +/// 아이콘 + 글자 한 줄 (회색 박스 없이) +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.icon, required this.text, this.trailing}); + + final IconData icon; + final String text; + + /// 강조 글자(브랜드색) — 예: 진행 중 + final String? trailing; + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppColors.text); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, size: 18, color: AppColors.textTertiary), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + text: keepAll(text), + children: [ + if (trailing != null) + TextSpan( + text: ' · $trailing', + style: const TextStyle( + color: AppColors.brand, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + style: style, + ), + ), + ], + ), + ); + } +} + +/// 예매·홈페이지·전화 — 있는 것만 보인다. 첫 번째만 채운 버튼 +class _Actions extends StatelessWidget { + const _Actions({required this.detail}); + + final EventDetail detail; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final booking = detail.bookingUrl; + final homepage = detail.homepage; + final phone = detail.phone; + final buttons = [ + if (booking != null) + FilledButton( + onPressed: () => openWebLink(context, booking), + child: Text(l10n.eventOpenBooking), + ), + if (homepage != null) + booking == null + ? FilledButton( + onPressed: () => openWebLink(context, homepage), + child: Text(l10n.eventOpenHomepage), + ) + : OutlinedButton( + onPressed: () => openWebLink(context, homepage), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: Text(l10n.eventOpenHomepage), + ), + if (phone != null) + OutlinedButton.icon( + onPressed: () => openPhone(context, phone), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + icon: const Icon(Icons.call_outlined, size: 18), + label: Text('${l10n.eventCall} · $phone'), + ), + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < buttons.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + buttons[i], + ], + ], + ); + } +} diff --git a/flutter/lib/features/event/presentation/pages/event_list_page.dart b/flutter/lib/features/event/presentation/pages/event_list_page.dart new file mode 100644 index 0000000..88b999e --- /dev/null +++ b/flutter/lib/features/event/presentation/pages/event_list_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/data/repositories/event_repository.dart'; +import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; +import 'package:wandergo/features/event/presentation/widgets/event_card.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 한 번에 받는 수 +const int _kPageSize = 20; + +/// 끝에서 이만큼 남으면 다음 쪽을 미리 받는다 +const int _kPrefetchThreshold = 5; + +/// 홈 축제 칸의 '전체 보기' — 같은 기간·반경을 거리순으로 끝까지 +class EventListPage extends ConsumerStatefulWidget { + const EventListPage({ + super.key, + required this.range, + required this.isUpcoming, + }); + + final DateRange range; + final bool isUpcoming; + + @override + ConsumerState createState() => _EventListPageState(); +} + +class _EventListPageState extends ConsumerState { + final List _items = []; + String? _cursor; + bool _hasNext = true; + bool _loading = false; + Object? _error; + + @override + void initState() { + super.initState(); + _loadMore(); + } + + Future _loadMore() async { + if (_loading || !_hasNext) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final base = await ref.read(basePointProvider.future); + final page = await ref + .read(eventRepositoryProvider) + .search( + range: widget.range, + near: base.point, + radiusM: kHomeEventRadiusM, + limit: _kPageSize, + cursor: _cursor, + ); + if (!mounted) return; + setState(() { + _items.addAll(page.items); + _cursor = page.nextCursor; + _hasNext = page.hasNext; + }); + } on Exception catch (e) { + // 문구는 LoadError 가 에러 사전에서 가져온다 + if (mounted) setState(() => _error = e); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text( + widget.isUpcoming ? l10n.homeUpcomingEvents : l10n.homeWeekendEvents, + ), + ), + body: _body(context), + ); + } + + Widget _body(BuildContext context) { + if (_items.isEmpty && _error == null && !_hasNext) { + return Center( + child: Text( + context.l10n.homeEventsEmpty, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + itemCount: _items.length + 1, + separatorBuilder: (_, index) => + index < _items.length - 1 ? const Divider() : const SizedBox.shrink(), + itemBuilder: (context, index) { + if (index >= _items.length - _kPrefetchThreshold && + _hasNext && + _error == null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); + } + if (index == _items.length) return _footer(); + final item = _items[index]; + return EventRow( + item: item, + onTap: () => context.push(AppRoutes.eventDetail(item.id)), + ); + }, + ); + } + + Widget _footer() { + final error = _error; + if (error != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: LoadError(error: error, onRetry: _loadMore), + ); + } + if (_loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ); + } + return const SizedBox(height: 24); + } +} diff --git a/flutter/lib/features/event/presentation/providers/event_providers.dart b/flutter/lib/features/event/presentation/providers/event_providers.dart new file mode 100644 index 0000000..04337fe --- /dev/null +++ b/flutter/lib/features/event/presentation/providers/event_providers.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/clock.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/data/repositories/event_repository.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; + +/// 홈 축제 칸 반경 — 대구 근교(경북)까지 거리순(대구 먼저). 2026-11-03 합의 +const int kHomeEventRadiusM = 120000; + +/// 주말이 비었을 때 대신 보이는 '다가오는' 기간(일) +const int kUpcomingDays = 30; + +/// 홈 가로 목록에 보이는 수 +const int kHomeEventLimit = 10; + +/// 홈 축제 칸의 내용 — 주말 행사, 비면 다가오는 행사 +class HomeEvents { + const HomeEvents({ + required this.items, + required this.range, + required this.isUpcoming, + }); + + final List items; + + /// 조회한 기간 — '전체 보기' 가 같은 기간을 연다 + final DateRange range; + + /// 주말이 비어 '다가오는 축제·행사' 로 바꿔 보이는지 + final bool isUpcoming; +} + +/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 120km 거리순, 비면 같은 반경 30일 +final homeEventsProvider = FutureProvider((ref) async { + final base = await ref.watch(basePointProvider.future); + final repo = ref.watch(eventRepositoryProvider); + final now = ref.watch(clockProvider)(); + + final weekend = weekendRange(now); + final weekendPage = await repo.search( + range: weekend, + near: base.point, + radiusM: kHomeEventRadiusM, + limit: kHomeEventLimit, + ); + if (weekendPage.items.isNotEmpty) { + return HomeEvents( + items: weekendPage.items, + range: weekend, + isUpcoming: false, + ); + } + + final upcoming = upcomingRange(now, days: kUpcomingDays); + final upcomingPage = await repo.search( + range: upcoming, + near: base.point, + radiusM: kHomeEventRadiusM, + limit: kHomeEventLimit, + ); + return HomeEvents( + items: upcomingPage.items, + range: upcoming, + isUpcoming: true, + ); +}); + +/// 행사 상세 +final eventDetailProvider = FutureProvider.family( + (ref, id) => ref.watch(eventRepositoryProvider).getEvent(id), +); diff --git a/flutter/lib/features/event/presentation/widgets/event_card.dart b/flutter/lib/features/event/presentation/widgets/event_card.dart new file mode 100644 index 0000000..fa44a9b --- /dev/null +++ b/flutter/lib/features/event/presentation/widgets/event_card.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/presentation/widgets/event_visuals.dart'; + +/// 홈 가로 목록 카드 너비·사진 높이 (디자인 캔버스 148×110) +const double kEventCardWidth = 148; +const double kEventCardImageHeight = 110; + +/// 홈 가로 목록의 한 칸 — 사진 · 기간(갈색) · 이름 · 지역·거리 (테두리 없음) +class EventCard extends StatelessWidget { + const EventCard({super.key, required this.item, required this.onTap}); + + final EventItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final localeName = context.l10n.localeName; + return SizedBox( + width: kEventCardWidth, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EventImage( + url: item.imageThumbUrl, + kind: item.kind, + license: item.imageLicense, + width: kEventCardWidth, + height: kEventCardImageHeight, + ), + const SizedBox(height: 10), + Text( + formatDateRange( + item.startDate, + item.endDate, + localeName: localeName, + ), + style: textTheme.bodySmall?.copyWith( + color: AppColors.eventDate, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + item.name, + style: textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + eventMeta(item), + style: textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +/// 세로 목록의 한 줄 — 작은 사진 + 기간 · 이름 · 분류·지역·거리, 아래 구분선 +class EventRow extends StatelessWidget { + const EventRow({super.key, required this.item, required this.onTap}); + + final EventItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final kind = item.kind?.label(l10n); + final meta = [ + ?kind, + eventMeta(item), + ].where((s) => s.isNotEmpty).join(' · '); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + EventImage( + url: item.imageThumbUrl, + kind: item.kind, + license: item.imageLicense, + width: 64, + height: 64, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.ongoing + ? '${formatDateRange(item.startDate, item.endDate, localeName: l10n.localeName)} · ${l10n.eventOngoing}' + : formatDateRange( + item.startDate, + item.endDate, + localeName: l10n.localeName, + ), + style: textTheme.bodySmall?.copyWith( + color: AppColors.eventDate, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + item.name, + style: textTheme.titleSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + meta, + style: textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/event/presentation/widgets/event_visuals.dart b/flutter/lib/features/event/presentation/widgets/event_visuals.dart new file mode 100644 index 0000000..79505b6 --- /dev/null +++ b/flutter/lib/features/event/presentation/widgets/event_visuals.dart @@ -0,0 +1,108 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; + +extension EventKindLabel on EventKind { + String label(AppLocalizations l10n) => switch (this) { + EventKind.festival => l10n.eventKindFestival, + EventKind.performance => l10n.eventKindPerformance, + EventKind.event => l10n.eventKindEvent, + EventKind.exhibition => l10n.eventKindExhibition, + }; +} + +/// 사진이 없을 때 분류별 자리 표시 색·아이콘 (디자인 캔버스의 자리 표시 톤) +({Color background, Color foreground, IconData icon}) _placeholderOf( + EventKind? kind, +) => switch (kind) { + EventKind.festival => ( + background: const Color(0xFFFFE7B8), + foreground: const Color(0xFF6E4A00), + icon: Icons.celebration_outlined, + ), + EventKind.performance => ( + background: const Color(0xFFDCE3F0), + foreground: const Color(0xFF34405A), + icon: Icons.theater_comedy_outlined, + ), + EventKind.exhibition => ( + background: const Color(0xFFF3DCE4), + foreground: const Color(0xFF6A2E44), + icon: Icons.palette_outlined, + ), + EventKind.event || null => ( + background: const Color(0xFFD7E9E3), + foreground: const Color(0xFF24584E), + icon: Icons.event_outlined, + ), +}; + +/// 목록 메타 정보 — "수성구 · 1.2km" (구·군이 없으면 행사 장소 이름) +String eventMeta(EventItem item) { + final parts = [ + ?(shortRegion(item.address) ?? item.eventPlace), + if (item.distanceM != null) formatDistance(item.distanceM!), + ]; + return parts.join(' · '); +} + +/// 행사 사진 — 공공누리 Type3 은 자르지 않고(변경 금지) 통째로 보인다. +/// [fit] 을 주지 않으면 Type3 은 contain, 나머지는 cover. +class EventImage extends StatelessWidget { + const EventImage({ + super.key, + required this.url, + required this.kind, + required this.license, + this.width, + this.height, + this.radius = kRadius, + this.fit, + }); + + final String? url; + final EventKind? kind; + final ImageLicense? license; + final double? width; + final double? height; + final double radius; + final BoxFit? fit; + + @override + Widget build(BuildContext context) { + final placeholder = _placeholderOf(kind); + final fallback = ColoredBox( + color: placeholder.background, + child: Center( + child: Icon(placeholder.icon, size: 28, color: placeholder.foreground), + ), + ); + final imageUrl = url; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: height, + child: imageUrl == null + ? fallback + : ColoredBox( + color: AppColors.placeholder, + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: + fit ?? + (license == ImageLicense.type3 + ? BoxFit.contain + : BoxFit.cover), + errorWidget: (_, _, _) => fallback, + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 0000000..acb07ce --- /dev/null +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; +import 'package:wandergo/features/event/presentation/widgets/event_card.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; +import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart'; +import 'package:wandergo/shared/widgets/language_button.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 홈 가로 목록 높이 — 사진 110 + 여백·글자 세 줄 +const double _kEventStripHeight = 186; + +/// 3 홈 — 지금은 기준 위치 · 제목 · 이번 주말 축제·행사 · 출처까지. +/// 검색창·배너·카테고리·AI·추천 코스·반려동물 장소 칸은 해당 기능을 만들 때 붙인다. +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + Future _refresh(WidgetRef ref) async { + // 현재 위치를 쓰는 경우 위치도 다시 받는다 + ref.invalidate(basePointProvider); + await ref.read(homeEventsProvider.future); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return Scaffold( + body: SafeArea( + bottom: false, + child: RefreshIndicator( + color: AppColors.brand, + onRefresh: () => _refresh(ref), + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const _Header(), + Padding( + padding: const EdgeInsets.fromLTRB( + kPagePadding, + 8, + kPagePadding, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _BaseLocationButton(), + const SizedBox(height: 4), + Text(keepAll(l10n.homeTitle), style: textTheme.titleLarge), + ], + ), + ), + const SizedBox(height: 24), + const _EventsSection(), + const SizedBox(height: 36), + const Padding( + padding: EdgeInsets.symmetric(horizontal: kPagePadding), + child: _Footer(), + ), + ], + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(kPagePadding, 16, kPagePadding, 8), + child: Row( + children: [ + // 로고 PNG 는 정사각형 안에 글자가 가운데 있어 가운데만 보이게 자른다(디자인과 같음) + Image.asset( + 'assets/images/logo.png', + width: 100, + height: 56, + fit: BoxFit.cover, + semanticLabel: context.l10n.appTitle, + ), + const Spacer(), + const LanguageButton(), + ], + ), + ); + } +} + +/// 기준 위치 — 누르면 동네·현재 위치를 고른다 +class _BaseLocationButton extends ConsumerWidget { + const _BaseLocationButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final choice = ref.watch(baseLocationProvider); + // 현재 위치를 못 받아 대구 도심으로 대신 보이는 중이면, 실제 기준인 동네 이름을 보인다 + final fellBack = ref.watch(basePointProvider).value?.fellBack ?? false; + final label = fellBack + ? kFallbackNeighborhood.label(l10n) + : choice.label(l10n); + return Semantics( + button: true, + label: l10n.baseLocationButtonLabel(label), + excludeSemantics: true, + child: InkWell( + onTap: () async { + final picked = await showRegionSheet(context, selected: choice); + if (picked != null && context.mounted) { + await applyBaseLocation(context, ref, picked); + } + }, + child: SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon( + AppIconData.pin, + size: 16, + color: AppColors.brand, + filled: true, + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: 4), + const AppIcon( + AppIconData.chevronDown, + size: 14, + color: AppColors.textSecondary, + ), + ], + ), + ), + ), + ); + } +} + +/// 이번 주말 축제·행사 — 비면 다가오는 축제·행사 +class _EventsSection extends ConsumerWidget { + const _EventsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final events = ref.watch(homeEventsProvider); + final data = events.value; + final title = data != null && data.isUpcoming + ? l10n.homeUpcomingEvents + : l10n.homeWeekendEvents; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + child: Row( + children: [ + Expanded(child: Text(title, style: textTheme.titleMedium)), + if (data != null && data.items.isNotEmpty) + TextButton( + onPressed: () => context.push( + AppRoutes.eventList( + data.range, + isUpcoming: data.isUpcoming, + ), + ), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + textStyle: textTheme.bodyMedium, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.seeAll), + const SizedBox(width: 2), + const AppIcon( + AppIconData.chevronRight, + size: 14, + color: AppColors.textTertiary, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + switch (events) { + AsyncData(:final value) when value.items.isEmpty => Padding( + padding: const EdgeInsets.symmetric( + horizontal: kPagePadding, + vertical: 8, + ), + child: Text( + keepAll(l10n.homeEventsEmpty), + style: textTheme.bodyMedium, + ), + ), + AsyncData(:final value) => SizedBox( + height: _kEventStripHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + itemCount: value.items.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final item = value.items[index]; + return EventCard( + item: item, + onTap: () => context.push(AppRoutes.eventDetail(item.id)), + ); + }, + ), + ), + AsyncError(:final error) => Padding( + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + child: LoadError( + error: error, + onRetry: () => ref.invalidate(homeEventsProvider), + ), + ), + _ => const _EventStripSkeleton(), + }, + ], + ); + } +} + +/// 불러오는 동안의 자리 — 사진 칸 세 개 +class _EventStripSkeleton extends StatelessWidget { + const _EventStripSkeleton(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _kEventStripHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + itemCount: 3, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, _) => Align( + alignment: Alignment.topCenter, + child: Container( + width: kEventCardWidth, + height: kEventCardImageHeight, + decoration: BoxDecoration( + color: AppColors.placeholder, + borderRadius: BorderRadius.circular(kRadius), + ), + ), + ), + ), + ); + } +} + +/// 꼬리말 — 데이터 출처(공공누리·KOPIS·문화포털 표기 의무)와 방문 전 확인 안내 +class _Footer extends StatelessWidget { + const _Footer(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final style = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(height: 18 / 12, color: AppColors.textHint); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox(height: 12), + Text(keepAll(l10n.footerSources), style: style), + const SizedBox(height: 10), + Text(keepAll(l10n.footerNotice), style: style), + const SizedBox(height: 10), + Text('© ${l10n.appTitle}', style: style), + ], + ); + } +} diff --git a/flutter/lib/features/location/data/location_service.dart b/flutter/lib/features/location/data/location_service.dart new file mode 100644 index 0000000..9d47223 --- /dev/null +++ b/flutter/lib/features/location/data/location_service.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:wandergo/features/location/domain/base_location.dart'; + +/// 위치 요청 제한 시간 — 넘으면 기준 동네로 대신한다 +const Duration _kLocationTimeout = Duration(seconds: 10); + +/// 기기 위치 — 앱을 쓰는 동안에만 쓰고, 좌표는 저장하지 않는다. +class LocationService { + const LocationService(); + + /// 위치 권한을 요청한다. 위치 서비스가 꺼져 있거나 거부하면 false + Future requestPermission() async { + if (!await Geolocator.isLocationServiceEnabled()) return false; + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + return _granted(permission); + } + + /// 현재 좌표. 권한이 없거나 받지 못하면 null (권한을 새로 묻지 않는다) + Future currentPoint() async { + try { + if (!await Geolocator.isLocationServiceEnabled()) return null; + if (!_granted(await Geolocator.checkPermission())) return null; + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + // 가까운 순 정렬에는 동네 수준이면 충분하다 + accuracy: LocationAccuracy.medium, + timeLimit: _kLocationTimeout, + ), + ); + return (lat: position.latitude, lng: position.longitude); + } on Exception { + // 시간 초과·일시 오류 — 호출부가 기준 동네로 대신한다 + return null; + } + } + + bool _granted(LocationPermission permission) => + permission == LocationPermission.whileInUse || + permission == LocationPermission.always; +} + +final locationServiceProvider = Provider( + (ref) => const LocationService(), +); diff --git a/flutter/lib/features/location/domain/base_location.dart b/flutter/lib/features/location/domain/base_location.dart new file mode 100644 index 0000000..27b09fa --- /dev/null +++ b/flutter/lib/features/location/domain/base_location.dart @@ -0,0 +1,71 @@ +/// 위도·경도 +typedef GeoPoint = ({double lat, double lng}); + +/// 1차 출시 지역(대구)의 기준 동네 — 운영자 큐레이션을 먼저 채우는 곳들. +/// +/// 좌표는 장소 DB 의 관광공사 장소에서 가져왔다(2026-11-04): +/// 대구 동성로거리 · 수성못 유원지 · 앞산카페거리. +enum Neighborhood { + downtown(lat: 35.87021, lng: 128.59409), + suseongmot(lat: 35.82760, lng: 128.61770), + apsan(lat: 35.83582, lng: 128.57911); + + const Neighborhood({required this.lat, required this.lng}); + + final double lat; + final double lng; + + GeoPoint get point => (lat: lat, lng: lng); +} + +/// 위치를 못 쓸 때 기준으로 삼는 동네 +const Neighborhood kFallbackNeighborhood = Neighborhood.downtown; + +/// 사용자가 고른 기준 위치 — 현재 위치 또는 동네 하나 +class BaseLocationChoice { + const BaseLocationChoice.current() : neighborhood = null; + + const BaseLocationChoice.neighborhood(Neighborhood this.neighborhood); + + /// null 이면 현재 위치 + final Neighborhood? neighborhood; + + bool get usesCurrentLocation => neighborhood == null; + + static const String _currentKey = 'current'; + + /// 저장 문자열 — 'current' 또는 동네 이름 + String get storageKey => neighborhood?.name ?? _currentKey; + + /// 저장 문자열을 읽는다. 모르는 값이면 null + static BaseLocationChoice? fromStorageKey(String? key) { + if (key == null) return null; + if (key == _currentKey) return const BaseLocationChoice.current(); + for (final n in Neighborhood.values) { + if (n.name == key) return BaseLocationChoice.neighborhood(n); + } + return null; + } + + @override + bool operator ==(Object other) => + other is BaseLocationChoice && other.neighborhood == neighborhood; + + @override + int get hashCode => neighborhood.hashCode; +} + +/// 실제로 조회에 쓰는 기준 좌표 +class BasePoint { + const BasePoint({ + required this.point, + required this.choice, + this.fellBack = false, + }); + + final GeoPoint point; + final BaseLocationChoice choice; + + /// 현재 위치를 고르고도 위치를 못 받아 [kFallbackNeighborhood] 를 쓴 경우 + final bool fellBack; +} diff --git a/flutter/lib/features/location/presentation/providers/base_location_provider.dart b/flutter/lib/features/location/presentation/providers/base_location_provider.dart new file mode 100644 index 0000000..26f89fa --- /dev/null +++ b/flutter/lib/features/location/presentation/providers/base_location_provider.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/storage/preferences.dart'; +import 'package:wandergo/features/location/data/location_service.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; + +/// 사용자가 고른 기준 위치. 고른 적이 없으면 [kFallbackNeighborhood] +final baseLocationProvider = + NotifierProvider( + BaseLocationNotifier.new, + ); + +class BaseLocationNotifier extends Notifier { + @override + BaseLocationChoice build() { + final saved = ref + .watch(sharedPreferencesProvider) + .getString(PrefKeys.baseLocation); + return BaseLocationChoice.fromStorageKey(saved) ?? + const BaseLocationChoice.neighborhood(kFallbackNeighborhood); + } + + Future select(BaseLocationChoice choice) async { + state = choice; + await ref + .read(sharedPreferencesProvider) + .setString(PrefKeys.baseLocation, choice.storageKey); + } +} + +/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하면 [kFallbackNeighborhood] 로 대신한다 +final basePointProvider = FutureProvider((ref) async { + final choice = ref.watch(baseLocationProvider); + final neighborhood = choice.neighborhood; + if (neighborhood != null) { + return BasePoint(point: neighborhood.point, choice: choice); + } + final current = await ref.watch(locationServiceProvider).currentPoint(); + if (current != null) return BasePoint(point: current, choice: choice); + return BasePoint( + point: kFallbackNeighborhood.point, + choice: choice, + fellBack: true, + ); +}); diff --git a/flutter/lib/features/location/presentation/widgets/region_sheet.dart b/flutter/lib/features/location/presentation/widgets/region_sheet.dart new file mode 100644 index 0000000..8eb0745 --- /dev/null +++ b/flutter/lib/features/location/presentation/widgets/region_sheet.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/features/location/data/location_service.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; + +extension NeighborhoodLabel on Neighborhood { + String label(AppLocalizations l10n) => switch (this) { + Neighborhood.downtown => l10n.regionDowntown, + Neighborhood.suseongmot => l10n.regionSuseongmot, + Neighborhood.apsan => l10n.regionApsan, + }; + + String hint(AppLocalizations l10n) => switch (this) { + Neighborhood.downtown => l10n.regionDowntownHint, + Neighborhood.suseongmot => l10n.regionSuseongmotHint, + Neighborhood.apsan => l10n.regionApsanHint, + }; +} + +extension BaseLocationChoiceLabel on BaseLocationChoice { + String label(AppLocalizations l10n) => + neighborhood?.label(l10n) ?? l10n.regionCurrentLocation; +} + +/// 기준 위치 고르기 시트. 고르지 않고 닫으면 null +Future showRegionSheet( + BuildContext context, { + BaseLocationChoice? selected, + bool includeCurrent = true, +}) { + return showModalBottomSheet( + context: context, + builder: (context) => + _RegionSheet(selected: selected, includeCurrent: includeCurrent), + ); +} + +/// 시트에서 고른 기준 위치를 적용한다. 현재 위치는 권한을 받은 경우에만 바꾼다. +Future applyBaseLocation( + BuildContext context, + WidgetRef ref, + BaseLocationChoice choice, +) async { + if (choice.usesCurrentLocation) { + final granted = await ref.read(locationServiceProvider).requestPermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.locationUnavailable)), + ); + } + return; + } + } + await ref.read(baseLocationProvider.notifier).select(choice); +} + +class _RegionSheet extends StatelessWidget { + const _RegionSheet({required this.selected, required this.includeCurrent}); + + final BaseLocationChoice? selected; + final bool includeCurrent; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final rows = [ + if (includeCurrent) + _RegionRow( + title: l10n.regionCurrentLocation, + leading: const Icon( + Icons.my_location, + size: 22, + color: AppColors.brand, + ), + selected: selected == const BaseLocationChoice.current(), + onTap: () => + Navigator.of(context).pop(const BaseLocationChoice.current()), + ), + for (final n in Neighborhood.values) + _RegionRow( + title: n.label(l10n), + subtitle: n.hint(l10n), + leading: const AppIcon( + AppIconData.pin, + size: 22, + color: AppColors.brand, + filled: true, + ), + selected: selected == BaseLocationChoice.neighborhood(n), + onTap: () => + Navigator.of(context).pop(BaseLocationChoice.neighborhood(n)), + ), + ]; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.regionSheetTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) const Divider(), + rows[i], + ], + ], + ), + ), + ); + } +} + +class _RegionRow extends StatelessWidget { + const _RegionRow({ + required this.title, + required this.leading, + required this.selected, + required this.onTap, + this.subtitle, + }); + + final String title; + final String? subtitle; + final Widget leading; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Semantics( + button: true, + selected: selected, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + leading, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleSmall), + if (subtitle != null) + Text(subtitle!, style: textTheme.bodyMedium), + ], + ), + ), + if (selected) + const Icon(Icons.check, size: 20, color: AppColors.brand), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart b/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart new file mode 100644 index 0000000..e7304be --- /dev/null +++ b/flutter/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/location/data/location_service.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; +import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart'; +import 'package:wandergo/features/onboarding/presentation/providers/onboarding_provider.dart'; +import 'package:wandergo/shared/widgets/language_button.dart'; + +/// 2 첫 실행 · 위치 권한 — 현재 위치를 쓸지, 동네를 직접 고를지 정한다 +class OnboardingPage extends ConsumerStatefulWidget { + const OnboardingPage({super.key}); + + @override + ConsumerState createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends ConsumerState { + /// 권한 요청 중 — 버튼을 두 번 누르지 못하게 한다 + bool _busy = false; + + Future _useCurrentLocation() async { + setState(() => _busy = true); + // 홈으로 넘어간 뒤에도 안내가 보이도록 앱 전체의 알림 영역을 미리 잡아 둔다 + final messenger = ScaffoldMessenger.of(context); + final unavailable = context.l10n.locationUnavailable; + final granted = await ref.read(locationServiceProvider).requestPermission(); + // 거부해도 막지 않는다 — 대구 도심을 기준으로 시작한다 + await _finish( + granted + ? const BaseLocationChoice.current() + : const BaseLocationChoice.neighborhood(kFallbackNeighborhood), + ); + if (!granted) { + messenger.showSnackBar(SnackBar(content: Text(unavailable))); + } + } + + Future _pickRegion() async { + final picked = await showRegionSheet(context, includeCurrent: false); + if (picked == null) return; + await _finish(picked); + } + + Future _finish(BaseLocationChoice choice) async { + await ref.read(baseLocationProvider.notifier).select(choice); + await ref.read(onboardingDoneProvider.notifier).complete(); + if (mounted) context.go(AppRoutes.home); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return Scaffold( + body: SafeArea( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Align( + alignment: Alignment.centerRight, + child: LanguageButton(compact: false), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/images/character.png', + width: 176, + height: 128, + excludeFromSemantics: true, + ), + const SizedBox(height: 24), + Text( + keepAll(l10n.onboardingTitle), + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + keepAll(l10n.onboardingBody), + style: textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + kPagePadding, + 0, + kPagePadding, + 28, + ), + child: Column( + children: [ + const _LocationNotice(), + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : _useCurrentLocation, + child: Text(l10n.onboardingUseLocation), + ), + const SizedBox(height: 4), + TextButton( + onPressed: _busy ? null : _pickRegion, + child: Text(l10n.onboardingPickRegion), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 위치를 왜·언제 쓰는지 — 아이콘 타일 + 두 줄 (회색 박스 없이) +class _LocationNotice extends StatelessWidget { + const _LocationNotice(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.brandSoft, + borderRadius: BorderRadius.circular(kRadius), + ), + alignment: Alignment.center, + child: const AppIcon( + AppIconData.pin, + size: 22, + color: AppColors.brand, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + keepAll(l10n.onboardingLocationTitle), + style: textTheme.titleSmall, + ), + const SizedBox(height: 3), + Text( + keepAll(l10n.onboardingLocationBody), + style: textTheme.bodyMedium, + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/onboarding/presentation/providers/onboarding_provider.dart b/flutter/lib/features/onboarding/presentation/providers/onboarding_provider.dart new file mode 100644 index 0000000..f7f661e --- /dev/null +++ b/flutter/lib/features/onboarding/presentation/providers/onboarding_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/storage/preferences.dart'; + +/// 첫 실행 화면(위치 권한 안내)을 마쳤는지 — 마치기 전에는 어느 화면이든 첫 실행 화면으로 보낸다 +final onboardingDoneProvider = NotifierProvider( + OnboardingDoneNotifier.new, +); + +class OnboardingDoneNotifier extends Notifier { + @override + bool build() => + ref.watch(sharedPreferencesProvider).getBool(PrefKeys.onboardingDone) ?? + false; + + Future complete() async { + state = true; + await ref + .read(sharedPreferencesProvider) + .setBool(PrefKeys.onboardingDone, true); + } +} diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb new file mode 100644 index 0000000..04f62a9 --- /dev/null +++ b/flutter/lib/l10n/app_en.arb @@ -0,0 +1,54 @@ +{ + "@@locale": "en", + "appTitle": "WanderGo", + "tabHome": "Home", + "tabMap": "Map", + "tabCourses": "My courses", + "tabMe": "Me", + "languageName": "English", + "languageShort": "EN", + "languageButtonLabel": "Change language, current {language}", + "languageSheetTitle": "Language", + "onboardingTitle": "Where to go?\nLet's plan it together.", + "onboardingBody": "Plan a day out with friends, matched to festival dates and pet-friendly spots.", + "onboardingLocationTitle": "Use your location to see nearby places first", + "onboardingLocationBody": "Only used while you use the app. You can turn it off in Settings anytime.", + "onboardingUseLocation": "Use my location", + "onboardingPickRegion": "Choose an area", + "regionSheetTitle": "Where should we start from?", + "regionCurrentLocation": "Current location", + "regionDowntown": "Downtown Daegu", + "regionDowntownHint": "Dongseongno · Kim Kwang-seok Street", + "regionSuseongmot": "Suseongmot Lake", + "regionSuseongmotHint": "Suseong-gu", + "regionApsan": "Apsan Cafe Street", + "regionApsanHint": "Daemyeong-dong, Nam-gu", + "locationUnavailable": "We couldn't get your location, so we're showing downtown Daegu.", + "baseLocationButtonLabel": "Change base location, current {place}", + "homeTitle": "Where to this weekend?", + "homeWeekendEvents": "Festivals & events this weekend", + "homeUpcomingEvents": "Upcoming festivals & events", + "homeEventsEmpty": "No festivals or events nearby yet.", + "seeAll": "See all", + "retry": "Try again", + "loadFailed": "Couldn't load this.", + "eventKindFestival": "Festival", + "eventKindPerformance": "Performance", + "eventKindEvent": "Event", + "eventKindExhibition": "Exhibition", + "eventOngoing": "Now on", + "eventPlace": "Venue", + "eventFee": "Price", + "eventTime": "Hours", + "eventAbout": "About", + "eventOpenBooking": "Tickets & info", + "eventOpenHomepage": "Website", + "eventCall": "Call", + "linkOpenFailed": "Couldn't open the link.", + "sourceLabel": "Source: {provider}", + "sourceAsOf": "Source: {provider} · as of {date}", + "footerSources": "Tourism data: Korea Tourism Organization TourAPI (KOGL Type 1)\nPerformance data: Korea Arts Management Service, KOPIS (www.kopis.or.kr)\nExhibition data: Korea Culture Information Service Agency, Culture Portal", + "footerNotice": "Place and opening details can change. Please check before you go.", + "comingSoonTitle": "Coming soon", + "comingSoonBody": "We're still building this screen." +} diff --git a/flutter/lib/l10n/app_ko.arb b/flutter/lib/l10n/app_ko.arb new file mode 100644 index 0000000..5cbc7c4 --- /dev/null +++ b/flutter/lib/l10n/app_ko.arb @@ -0,0 +1,79 @@ +{ + "@@locale": "ko", + "appTitle": "WanderGo", + "tabHome": "홈", + "tabMap": "지도", + "tabCourses": "내 코스", + "tabMe": "내 정보", + "languageName": "한국어", + "@languageName": { + "description": "지금 쓰는 언어의 자기 이름 — 언어 버튼에 그대로 보인다" + }, + "languageShort": "KO", + "languageButtonLabel": "언어 변경, 현재 {language}", + "@languageButtonLabel": { + "description": "언어 버튼의 화면 읽기 프로그램용 이름", + "placeholders": { + "language": {"type": "String"} + } + }, + "languageSheetTitle": "언어", + "onboardingTitle": "어디 갈지,\n같이 짜 볼까요?", + "onboardingBody": "축제 일정과 반려동물 동반 조건까지 맞춘 하루 코스를 친구와 함께 만들어요.", + "onboardingLocationTitle": "현재 위치를 쓰면 가까운 곳부터 보여 드려요", + "onboardingLocationBody": "앱을 쓰는 동안에만 쓰고, 설정에서 언제든 끌 수 있어요.", + "onboardingUseLocation": "현재 위치 사용하기", + "onboardingPickRegion": "지역 직접 고르기", + "regionSheetTitle": "어디를 기준으로 볼까요?", + "regionCurrentLocation": "현재 위치", + "regionDowntown": "대구 도심", + "regionDowntownHint": "동성로·김광석길·근대골목", + "regionSuseongmot": "수성못", + "regionSuseongmotHint": "수성구", + "regionApsan": "앞산 카페거리", + "regionApsanHint": "남구 대명동", + "locationUnavailable": "현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.", + "baseLocationButtonLabel": "기준 위치 변경, 현재 {place}", + "@baseLocationButtonLabel": { + "placeholders": { + "place": {"type": "String"} + } + }, + "homeTitle": "이번 주말, 어디 갈까요?", + "homeWeekendEvents": "이번 주말 축제·행사", + "homeUpcomingEvents": "다가오는 축제·행사", + "homeEventsEmpty": "근처에 예정된 축제·행사가 아직 없어요.", + "seeAll": "전체 보기", + "retry": "다시 시도", + "loadFailed": "불러오지 못했어요.", + "eventKindFestival": "축제", + "eventKindPerformance": "공연", + "eventKindEvent": "행사", + "eventKindExhibition": "전시", + "eventOngoing": "진행 중", + "eventPlace": "장소", + "eventFee": "요금", + "eventTime": "시간", + "eventAbout": "소개", + "eventOpenBooking": "예매·관람 안내", + "eventOpenHomepage": "홈페이지", + "eventCall": "전화 걸기", + "linkOpenFailed": "링크를 열지 못했어요.", + "sourceLabel": "출처: {provider}", + "@sourceLabel": { + "placeholders": { + "provider": {"type": "String"} + } + }, + "sourceAsOf": "출처: {provider} · {date} 기준", + "@sourceAsOf": { + "placeholders": { + "provider": {"type": "String"}, + "date": {"type": "String"} + } + }, + "footerSources": "관광 정보 출처: 한국관광공사 TourAPI · 공공누리 제1유형\n공연 정보 출처: (재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)\n전시 정보 출처: 한국문화정보원 문화포털", + "footerNotice": "장소·영업 정보는 바뀔 수 있으니 방문 전에 확인하세요.", + "comingSoonTitle": "곧 만나요", + "comingSoonBody": "이 화면은 아직 만드는 중이에요." +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 0d77b72..ba79cac 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,67 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:wandergo/app.dart'; +import 'package:wandergo/core/storage/preferences.dart'; Future main() async { - // dotenv 로드 — .env 파일이 없으면 기본값으로 동작 WidgetsFlutterBinding.ensureInitialized(); + // dotenv 로드 — .env 파일이 없으면 기본값으로 동작 try { await dotenv.load(fileName: '.env'); } catch (_) { // .env 파일이 없는 경우 무시 (기본값 사용) } + final preferences = await SharedPreferences.getInstance(); runApp( - // Riverpod Provider Scope 적용 - const ProviderScope( - child: MyApp(), + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(preferences)], + child: const WanderGoApp(), ), ); } - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'TMA App', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const HomeView(), - ); - } -} - -class HomeView extends StatelessWidget { - const HomeView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('TMA Global Architecture'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Riverpod & Dio Interceptor Setup Complete.'), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')), - ); - }, - child: const Text('Show Global Alert Test'), - ), - ], - ), - ), - ); - } -} diff --git a/flutter/lib/shared/utils/open_link.dart b/flutter/lib/shared/utils/open_link.dart new file mode 100644 index 0000000..f733f62 --- /dev/null +++ b/flutter/lib/shared/utils/open_link.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; + +/// 웹 주소를 앱 밖(브라우저)으로 연다. 주소에 http(s) 가 없으면 https 로 본다. +Future openWebLink(BuildContext context, String url) { + final withScheme = RegExp(r'^https?://', caseSensitive: false).hasMatch(url) + ? url + : 'https://$url'; + return _launch(context, Uri.tryParse(withScheme)); +} + +/// 전화 앱을 연다 — 숫자와 + 만 남긴다 +Future openPhone(BuildContext context, String phone) { + final digits = phone.replaceAll(RegExp(r'[^0-9+]'), ''); + return _launch( + context, + digits.isEmpty ? null : Uri(scheme: 'tel', path: digits), + ); +} + +Future _launch(BuildContext context, Uri? uri) async { + final messenger = ScaffoldMessenger.of(context); + final failed = context.l10n.linkOpenFailed; + final opened = + uri != null && await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) messenger.showSnackBar(SnackBar(content: Text(failed))); +} diff --git a/flutter/lib/shared/widgets/coming_soon_page.dart b/flutter/lib/shared/widgets/coming_soon_page.dart new file mode 100644 index 0000000..e09141e --- /dev/null +++ b/flutter/lib/shared/widgets/coming_soon_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; + +/// 아직 만들지 않은 탭의 자리 — 지도·내 코스·내 정보를 만들면 교체한다 +class ComingSoonPage extends StatelessWidget { + const ComingSoonPage({super.key, required this.title}); + + /// 탭 이름(상단 제목) + final String title; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/character.png', + width: 132, + height: 96, + excludeFromSemantics: true, + ), + const SizedBox(height: 16), + Text(l10n.comingSoonTitle, style: textTheme.titleMedium), + const SizedBox(height: 6), + Text( + keepAll(l10n.comingSoonBody), + style: textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/language_button.dart b/flutter/lib/shared/widgets/language_button.dart new file mode 100644 index 0000000..e52ccfe --- /dev/null +++ b/flutter/lib/shared/widgets/language_button.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/l10n/locale_provider.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; + +/// 언어 버튼 — 홈은 짧은 표기("KO"), 첫 실행 화면은 언어 이름("한국어")과 펼침 표시 +class LanguageButton extends ConsumerWidget { + const LanguageButton({super.key, this.compact = true}); + + final bool compact; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + return Semantics( + button: true, + label: l10n.languageButtonLabel(l10n.languageName), + excludeSemantics: true, + child: OutlinedButton( + onPressed: () => _openSheet(context, ref), + style: OutlinedButton.styleFrom( + minimumSize: Size(44, compact ? 44 : 40), + padding: EdgeInsets.only(left: 12, right: compact ? 12 : 10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon(AppIconData.globe, size: 18, color: AppColors.text), + const SizedBox(width: 6), + Text( + compact ? l10n.languageShort : l10n.languageName, + style: Theme.of(context).textTheme.labelLarge, + ), + if (!compact) ...[ + const SizedBox(width: 6), + const AppIcon( + AppIconData.chevronDown, + size: 14, + color: AppColors.textTertiary, + ), + ], + ], + ), + ), + ); + } + + Future _openSheet(BuildContext context, WidgetRef ref) async { + final current = Localizations.localeOf(context); + final picked = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.languageSheetTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + for (final locale in AppLocalizations.supportedLocales) + _LanguageRow( + // 각 언어는 그 언어로 자기 이름을 보인다 + name: lookupAppLocalizations(locale).languageName, + selected: locale.languageCode == current.languageCode, + onTap: () => Navigator.of(context).pop(locale), + ), + ], + ), + ), + ), + ); + if (picked != null) { + await ref.read(appLocaleProvider.notifier).select(picked); + } + } +} + +class _LanguageRow extends StatelessWidget { + const _LanguageRow({ + required this.name, + required this.selected, + required this.onTap, + }); + + final String name; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 52, + child: Row( + children: [ + Expanded( + child: Text( + name, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (selected) + const Icon(Icons.check, size: 20, color: AppColors.brand), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/load_error.dart b/flutter/lib/shared/widgets/load_error.dart new file mode 100644 index 0000000..0f3991b --- /dev/null +++ b/flutter/lib/shared/widgets/load_error.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/api/api_exception.dart'; +import 'package:wandergo/core/api/error_lexicon.dart'; +import 'package:wandergo/core/api/error_lexicon.g.dart' show kFallbackErrorCode; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; + +/// 화면에 보일 실패 문구 — 에러 사전에서만 가져온다(여기서 문구를 만들지 않는다). +/// dio 인터셉터가 실어 준 [ApiException] 이 없으면 SYS_001. +String errorMessageOf(Object error) { + if (error is DioException) { + final api = error.error; + if (api is ApiException) return api.message; + } + if (error is ApiException) return error.message; + return ErrorLexicon.getMessage(kFallbackErrorCode); +} + +/// 불러오기 실패 — 아이콘 + 작은 글씨 + 다시 시도 (회색 박스 없이) +class LoadError extends StatelessWidget { + const LoadError({super.key, required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon( + Icons.error_outline, + size: 18, + color: AppColors.textTertiary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + errorMessageOf(error), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom(foregroundColor: AppColors.brand), + child: Text(context.l10n.retry), + ), + ], + ); + } +} diff --git a/flutter/linux/flutter/generated_plugin_registrant.cc b/flutter/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/flutter/linux/flutter/generated_plugin_registrant.cc +++ b/flutter/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/flutter/linux/flutter/generated_plugins.cmake b/flutter/linux/flutter/generated_plugins.cmake index ce58916..7e7bd77 100644 --- a/flutter/linux/flutter/generated_plugins.cmake +++ b/flutter/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 67d652c..058ee67 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,17 @@ import FlutterMacOS import Foundation import flutter_secure_storage_darwin +import geolocator_apple +import package_info_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 813608b..24cefbc 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -41,6 +41,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: @@ -113,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" dio: dependency: "direct main" description: @@ -145,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -153,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: "94515cbc3d560e7aefb40cdf683ee3f5063d23f7b689b431b4e7d3dec83bc06a" + url: "https://pub.dev" + source: hosted + version: "3.4.8" flutter_dotenv: dependency: "direct main" description: @@ -174,6 +230,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_riverpod: dependency: "direct main" description: @@ -226,10 +287,18 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "100c19dcc4ff60083e95a44a6f44f3a1bb887d353c5d86f6a9b688aa3cb46236" + url: "https://pub.dev" + source: hosted + version: "2.3.2" flutter_test: dependency: "direct dev" description: flutter @@ -248,6 +317,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09" + url: "https://pub.dev" + source: hosted + version: "14.1.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22 + url: "https://pub.dev" + source: hosted + version: "5.2.1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: a1e177f3081089724f2095856aeb96eb8d7d1c59e425aed8dd4de7aa2c11ab66 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -256,6 +389,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -264,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -280,6 +437,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -400,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "1db70031049945ac032e3d1c7a53739ce8c3f721d0e3c15aecfc2858ea5112a4" + url: "https://pub.dev" + source: hosted + version: "2.1.3" package_config: dependency: transitive description: @@ -408,6 +581,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -416,6 +605,14 @@ 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: @@ -464,6 +661,14 @@ packages: 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: @@ -504,6 +709,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: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + 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: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + 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: @@ -565,6 +834,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: @@ -597,6 +906,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: @@ -637,6 +954,102 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "82885f6ac633044e7a4e4d8d745724a67aa0dc50af5d8a81ec29379f63c29035" + url: "https://pub.dev" + source: hosted + version: "1.2.6" + 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: "4c9d2e7121eea8b4f160e6e435900a0ae7214cfc97c2d6cf84c25532b789b50d" + url: "https://pub.dev" + source: hosted + version: "1.3.4" vector_math: dependency: transitive description: @@ -697,10 +1110,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -709,6 +1122,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: @@ -719,4 +1140,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 9f7a4db..035426a 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ -name: tma_app -description: "A new Flutter project." +name: wandergo +description: "WanderGo — 대구에서 시작하는 데이트·나들이 코스 앱" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -38,6 +38,15 @@ dependencies: flutter_dotenv: ^5.2.1 flutter_riverpod: ^3.3.1 flutter_secure_storage: ^10.0.0 + go_router: ^17.5.0 + flutter_svg: ^2.3.2 + shared_preferences: ^2.5.6 + geolocator: ^14.1.2 + url_launcher: ^6.3.3 + cached_network_image: ^3.4.1 + intl: ^0.20.2 + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: @@ -61,9 +70,13 @@ flutter: # the material Icons class. uses-material-design: true + # 한/영 문구 — lib/l10n/*.arb 에서 생성 (l10n.yaml) + generate: true + # 런타임 환경변수 — flutter_dotenv 로 로드 assets: - .env + - assets/images/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/flutter/test/core/utils/format_test.dart b/flutter/test/core/utils/format_test.dart new file mode 100644 index 0000000..6f33d80 --- /dev/null +++ b/flutter/test/core/utils/format_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:wandergo/core/utils/format.dart'; + +void main() { + // 앱에서는 flutter_localizations 가 지금 언어의 날짜 표기 데이터를 준비한다 + setUpAll(() => initializeDateFormatting('en')); + + group('formatDistance', () { + test('1km 미만은 m, 100km 미만은 소수 한 자리 km, 그보다 멀면 정수 km', () { + expect(formatDistance(850), '850m'); + expect(formatDistance(999.6), '1.0km'); + expect(formatDistance(1240), '1.2km'); + expect(formatDistance(85300), '85.3km'); + expect(formatDistance(123456), '123km'); + }); + }); + + group('formatDateRange', () { + test('한국어는 월.일, 하루짜리는 한 날짜만', () { + expect(formatDateRange('2026-10-31', '2026-11-02'), '10.31–11.2'); + expect(formatDateRange('2026-11-08', '2026-11-08'), '11.8'); + }); + + test('영어는 그 언어의 월·일 표기', () { + expect( + formatDateRange('2026-10-31', '2026-11-02', localeName: 'en'), + 'Oct 31 – Nov 2', + ); + }); + + test('형식이 틀리면 원문을 그대로 보인다', () { + expect( + formatDateRange('2026.10.31', '2026.11.02'), + '2026.10.31–2026.11.02', + ); + }); + }); + + group('shortRegion', () { + test('시·도 다음 단어(구·군·시)를 꺼낸다', () { + expect(shortRegion('대구광역시 수성구 두산동 512'), '수성구'); + expect(shortRegion('경상북도 포항시 북구 해안로 1'), '포항시'); + expect(shortRegion('경북 경주시 첨성로 1'), '경주시'); + }); + + test('시·도로 시작하지 않거나 비어 있으면 null', () { + expect(shortRegion(null), isNull); + expect(shortRegion('수성못 일원'), isNull); + expect(shortRegion('대구광역시'), isNull); + }); + }); +} diff --git a/flutter/test/core/utils/keep_all_test.dart b/flutter/test/core/utils/keep_all_test.dart new file mode 100644 index 0000000..bdc7751 --- /dev/null +++ b/flutter/test/core/utils/keep_all_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; + +void main() { + group('keepAll', () { + test('단어 안의 글자 사이에만 줄바꿈 금지 글자를 넣는다', () { + expect(keepAll('어디 갈지'), '어⁠디 갈⁠지'); + }); + + test('공백·줄바꿈은 그대로 두고, 보이는 글자는 바뀌지 않는다', () { + const text = '어디 갈지,\n같이 짜 볼까요?'; + final result = keepAll(text); + expect(result.replaceAll('⁠', ''), text); + expect(result.split('\n'), hasLength(2)); + }); + + test('빈 문자열·한 글자는 그대로', () { + expect(keepAll(''), ''); + expect(keepAll('홈'), '홈'); + }); + }); +} diff --git a/flutter/test/core/utils/weekend_test.dart b/flutter/test/core/utils/weekend_test.dart new file mode 100644 index 0000000..d165a2e --- /dev/null +++ b/flutter/test/core/utils/weekend_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/core/utils/weekend.dart'; + +/// 한국 시각으로 만든 순간(UTC 로 바꿔 넘긴다) +DateTime kst(int y, int m, int d, [int h = 12]) => + DateTime.utc(y, m, d, h).subtract(const Duration(hours: 9)); + +void main() { + group('weekendRange', () { + test('평일이면 다가오는 토·일', () { + // 2026-11-04 수요일 + expect(weekendRange(kst(2026, 11, 4)), ( + from: '2026-11-07', + to: '2026-11-08', + )); + }); + + test('토요일이면 오늘·내일, 일요일이면 오늘 하루', () { + expect(weekendRange(kst(2026, 11, 7)), ( + from: '2026-11-07', + to: '2026-11-08', + )); + expect(weekendRange(kst(2026, 11, 8)), ( + from: '2026-11-08', + to: '2026-11-08', + )); + }); + + test('기기 시간대와 상관없이 한국 날짜로 계산한다', () { + // UTC 로는 아직 토요일 15:30 이지만 한국은 일요일 00:30 — 일요일 하루만 + expect(weekendRange(DateTime.utc(2026, 11, 7, 15, 30)), ( + from: '2026-11-08', + to: '2026-11-08', + )); + }); + + test('월을 넘어가는 주말', () { + // 2026-10-30 금요일 → 10-31(토)·11-01(일) + expect(weekendRange(kst(2026, 10, 30)), ( + from: '2026-10-31', + to: '2026-11-01', + )); + }); + }); + + group('upcomingRange', () { + test('오늘을 포함해 N일', () { + expect(upcomingRange(kst(2026, 11, 4), days: 30), ( + from: '2026-11-04', + to: '2026-12-03', + )); + }); + }); +} diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index 1998e5f..d69b823 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:tma_app/core/api/api_exception.dart'; -import 'package:tma_app/core/api/error_lexicon.dart'; +import 'package:wandergo/core/api/api_exception.dart'; +import 'package:wandergo/core/api/error_lexicon.dart'; void main() { group('ErrorLexicon', () { diff --git a/flutter/test/features/event_models_test.dart b/flutter/test/features/event_models_test.dart new file mode 100644 index 0000000..e8d9c7d --- /dev/null +++ b/flutter/test/features/event_models_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; + +void main() { + group('EventPage.fromJson', () { + test('백엔드 목록 응답(data)을 읽는다 — 숫자는 정수·실수 모두 받는다', () { + final page = EventPage.fromJson({ + 'items': [ + { + 'id': 'a1', + 'name': '수성빛예술제', + 'kind': 'festival', + 'startDate': '2026-11-27', + 'endDate': '2026-12-06', + 'genre': null, + 'ongoing': false, + 'address': '대구광역시 수성구 두산동', + 'eventPlace': '수성못 일원', + 'lat': 35.8282, + 'lng': 128, + 'distanceM': 1240, + 'feeText': '무료', + 'imageThumbUrl': null, + 'imageLicense': 'Type3', + }, + ], + 'pageInfo': {'hasNext': true, 'nextCursor': 'abc', 'total': null}, + }); + + expect(page.hasNext, isTrue); + expect(page.nextCursor, 'abc'); + final item = page.items.single; + expect(item.kind, EventKind.festival); + expect(item.lng, 128.0); + expect(item.distanceM, 1240.0); + expect(item.imageLicense, ImageLicense.type3); + }); + + test('모르는 분류·사진 유형은 null 로 둔다(앱이 죽지 않게)', () { + final item = EventItem.fromJson({ + 'id': 'a2', + 'name': '새 분류', + 'kind': 'workshop', + 'startDate': '2026-11-07', + 'endDate': '2026-11-07', + 'ongoing': true, + 'imageLicense': 'Type9', + }); + expect(item.kind, isNull); + expect(item.imageLicense, isNull); + }); + }); + + group('EventDetail.fromJson', () { + test('상세 필드와 출처를 읽는다', () { + final detail = EventDetail.fromJson({ + 'id': 'b1', + 'name': '라보엠', + 'kind': 'performance', + 'startDate': '2026-11-07', + 'endDate': '2026-11-07', + 'genre': '오페라', + 'ongoing': false, + 'overview': '소개', + 'playtimeText': '토요일(19:30)', + 'homepage': null, + 'phone': '053-000-0000', + 'bookingUrl': 'https://tickets.test/1', + 'imageUrl': 'https://kopis.or.kr/poster.jpg', + 'attribution': {'provider': 'KOPIS', 'asOf': null}, + }); + expect(detail.item.genre, '오페라'); + expect(detail.bookingUrl, 'https://tickets.test/1'); + expect(detail.attribution.provider, 'KOPIS'); + }); + }); + + group('BaseLocationChoice', () { + test('저장 문자열로 오가도 같은 값이다', () { + const choices = [ + BaseLocationChoice.current(), + BaseLocationChoice.neighborhood(Neighborhood.suseongmot), + ]; + for (final c in choices) { + expect(BaseLocationChoice.fromStorageKey(c.storageKey), c); + } + expect(BaseLocationChoice.fromStorageKey('seoul'), isNull); + expect(BaseLocationChoice.fromStorageKey(null), isNull); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index bc87675..8cae972 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -1,15 +1,182 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tma_app/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wandergo/app.dart'; +import 'package:wandergo/core/clock.dart'; +import 'package:wandergo/core/storage/preferences.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/data/repositories/event_repository.dart'; +import 'package:wandergo/features/location/data/location_service.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; + +/// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08 +final DateTime _now = DateTime.utc(2026, 11, 4, 3); + +EventItem _event( + String id, + String name, { + EventKind kind = EventKind.festival, +}) => EventItem( + id: id, + name: name, + kind: kind, + startDate: '2026-11-07', + endDate: '2026-11-08', + ongoing: false, + address: '대구광역시 수성구 두산동', + distanceM: 1240, +); + +/// 가짜 행사 API — 받은 조건을 기록하고, 주말·다가오는 기간에 따라 다른 목록을 준다 +class _FakeEventRepository implements EventRepository { + _FakeEventRepository({this.weekend = const [], this.upcoming = const []}); + + final List weekend; + final List upcoming; + final List<({DateRange range, GeoPoint? near})> calls = []; + + @override + Future search({ + required DateRange range, + GeoPoint? near, + int? radiusM, + int limit = 20, + String? cursor, + }) async { + calls.add((range: range, near: near)); + final isWeekend = range == weekendRange(_now); + return EventPage(items: isWeekend ? weekend : upcoming, hasNext: false); + } + + @override + Future getEvent(String id) async => EventDetail( + item: _event(id, '수성빛예술제'), + attribution: const Attribution(provider: '한국관광공사', asOf: '2026-10-15'), + overview: '수성못에서 열리는 빛 축제', + playtimeText: '17:00~22:00', + phone: '053-000-0000', + ); +} + +class _FakeLocationService implements LocationService { + const _FakeLocationService({this.granted = true}); + + final bool granted; + + @override + Future requestPermission() async => granted; + + @override + Future currentPoint() async => + granted ? (lat: 35.85, lng: 128.6) : null; +} + +Future<_FakeEventRepository> _pumpApp( + WidgetTester tester, { + Map prefs = const {}, + _FakeEventRepository? repo, + bool locationGranted = true, +}) async { + SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs}); + final preferences = await SharedPreferences.getInstance(); + final events = repo ?? _FakeEventRepository(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(preferences), + eventRepositoryProvider.overrideWithValue(events), + locationServiceProvider.overrideWithValue( + _FakeLocationService(granted: locationGranted), + ), + clockProvider.overrideWithValue(() => _now), + ], + child: const WanderGoApp(), + ), + ); + await tester.pumpAndSettle(); + return events; +} + +/// 화면 글자는 단어 단위 줄바꿈용 보이지 않는 글자를 품고 있어, 그 글자를 빼고 찾는다 +Finder _text(String text) => find.byWidgetPredicate( + (w) => + w is Text && + (w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text, +); void main() { - testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async { - await tester.pumpWidget( - const ProviderScope(child: MyApp()), + testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async { + final repo = await _pumpApp( + tester, + repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]), ); - expect(find.byType(MaterialApp), findsOneWidget); - expect(find.byType(Scaffold), findsOneWidget); + expect(_text('어디 갈지,\n같이 짜 볼까요?'), findsOneWidget); + await tester.tap(find.text('지역 직접 고르기')); + await tester.pumpAndSettle(); + await tester.tap(find.text('수성못')); + await tester.pumpAndSettle(); + + expect(_text('이번 주말, 어디 갈까요?'), findsOneWidget); + expect(find.text('수성못'), findsOneWidget); + expect(find.text('이번 주말 축제·행사'), findsOneWidget); + expect(find.text('수성빛예술제'), findsOneWidget); + expect(find.text('수성구 · 1.2km'), findsOneWidget); + expect(repo.calls.single.near, Neighborhood.suseongmot.point); + }); + + testWidgets('위치 권한을 거부하면 대구 도심을 기준으로 시작하고 안내한다', (tester) async { + await _pumpApp(tester, locationGranted: false); + + await tester.tap(find.text('현재 위치 사용하기')); + await tester.pumpAndSettle(); + + expect(find.text('대구 도심'), findsOneWidget); + expect(_text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget); + }); + + testWidgets('주말 행사가 없으면 다가오는 축제·행사(30일)로 바꿔 보인다', (tester) async { + final repo = await _pumpApp( + tester, + prefs: {'onboarding_done': true}, + repo: _FakeEventRepository(upcoming: [_event('e2', '대구 재즈 페스티벌')]), + ); + + expect(find.text('다가오는 축제·행사'), findsOneWidget); + expect(find.text('대구 재즈 페스티벌'), findsOneWidget); + expect(repo.calls.map((c) => c.range).toList(), [ + (from: '2026-11-07', to: '2026-11-08'), + (from: '2026-11-04', to: '2026-12-03'), + ]); + }); + + testWidgets('행사를 누르면 상세에 시간·전화·출처가 보인다', (tester) async { + await _pumpApp( + tester, + prefs: {'onboarding_done': true}, + repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]), + ); + + await tester.tap(find.text('수성빛예술제')); + await tester.pumpAndSettle(); + + expect(find.byType(BackButton), findsOneWidget); + expect(_text('17:00~22:00'), findsOneWidget); + expect(find.textContaining('053-000-0000'), findsOneWidget); + expect(find.text('출처: 한국관광공사 · 2026-10-15 기준'), findsOneWidget); + }); + + testWidgets('언어를 English 로 바꾸면 탭 이름이 영어로 바뀐다', (tester) async { + await _pumpApp(tester, prefs: {'onboarding_done': true}); + + await tester.tap(find.text('KO')); + await tester.pumpAndSettle(); + await tester.tap(find.text('English')); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('My courses'), findsOneWidget); }); } diff --git a/flutter/web/manifest.json b/flutter/web/manifest.json index 5650593..4d6b0c4 100644 --- a/flutter/web/manifest.json +++ b/flutter/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "tma_app", - "short_name": "tma_app", + "name": "WanderGo", + "short_name": "WanderGo", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 0c50753..dc0b7d1 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index d0b33f8..d5c8ce2 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + geolocator_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST