generated from ttipo/project-base
Compare commits
3 Commits
170be20b6d
...
b6abc3d118
| Author | SHA1 | Date | |
|---|---|---|---|
| b6abc3d118 | |||
| 19f3574d97 | |||
| 0f306e71ba |
@@ -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))`
|
||||
|
||||
@@ -28,6 +28,9 @@ docker compose -f docker-compose.runner.yml --env-file .env logs -f runner
|
||||
# "Runner registered successfully" 가 보이면 성공
|
||||
```
|
||||
|
||||
> 같은 PC 에 다른 저장소의 러너가 있다면 `RUNNER_NAME` 을 서로 다르게 짓습니다.
|
||||
> compose 프로젝트 이름과 컨테이너 이름이 모두 `RUNNER_NAME` 을 따르므로, 같으면 기존 러너를 덮어씁니다.
|
||||
|
||||
저장소 **Settings → Actions → Runners** 에서 러너가 **Idle** 로 표시되면 준비 완료입니다.
|
||||
이후 푸시하면 Actions 탭에서 실행이 시작됩니다.
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
#
|
||||
# 사용법은 ci/README.md 참조.
|
||||
|
||||
# compose 프로젝트 이름 — 지정하지 않으면 폴더 이름(ci)이 되어, 같은 PC 에서 다른 저장소의
|
||||
# ci/ 러너와 이름이 겹쳐 그 러너를 덮어쓸 수 있다. 러너 이름을 그대로 쓴다.
|
||||
name: ${RUNNER_NAME:-gitea-runner}
|
||||
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
|
||||
+3
-1
@@ -51,4 +51,6 @@ app.*.map.json
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.production
|
||||
# 한/영 문구 생성물 — flutter pub get 이 lib/l10n/*.arb 로 다시 만든다 (l10n.yaml)
|
||||
/lib/l10n/generated/
|
||||
|
||||
+56
-11
@@ -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:<BACKEND_PORT>/api` |
|
||||
| 실기기(같은 와이파이) | `http://<PC 의 사설 IP>:<BACKEND_PORT>/api` |
|
||||
| iOS 시뮬레이터 | `http://localhost:<BACKEND_PORT>/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()`.
|
||||
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.tma_app"
|
||||
namespace = "net.wandergo.app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -21,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.tma_app"
|
||||
applicationId = "net.wandergo.app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 개발 중에는 로컬 백엔드(http://10.0.2.2:포트)를 쓴다. 디버그 빌드에만 평문 HTTP 를 허용한다 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 백엔드 API 호출 (디버그 매니페스트에만 있으면 릴리스 빌드에서 통신이 막힌다) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 가까운 장소·행사 정렬용 현재 위치 — 앱을 쓰는 동안에만 쓴다(백그라운드 위치 없음) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<application
|
||||
android:label="tma_app"
|
||||
android:label="WanderGo"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -41,5 +46,14 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- 행사 예매·홈페이지 링크와 전화 걸기를 외부 앱으로 연다 (url_launcher) -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.DIAL"/>
|
||||
<data android:scheme="tel"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.example.tma_app
|
||||
package net.wandergo.app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
|
||||
with Reserved Font Name Pretendard.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
@@ -372,7 +372,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -388,7 +388,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -405,7 +405,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -420,7 +420,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -551,7 +551,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -573,7 +573,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -7,15 +7,20 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tma App</string>
|
||||
<string>WanderGo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>ko</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>tma_app</string>
|
||||
<string>WanderGo</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -26,6 +31,13 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>가까운 장소와 축제·행사를 거리순으로 보여 드리는 데 씁니다.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 지금 시각 — 테스트에서 날짜를 고정하려고 주입한다
|
||||
final clockProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
@@ -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(
|
||||
'<path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.25v-5.25h-5.5V20H5a1 1 0 0 1-1-1z"{fill}/>',
|
||||
),
|
||||
map(
|
||||
'<path d="M9 4.5L3.5 6.5v13l5.5-2 6 2 5.5-2v-13l-5.5 2z"{fill}/><path d="M9 4.5v13M15 6.5v13"/>',
|
||||
),
|
||||
route(
|
||||
'<circle cx="6.5" cy="17.5" r="2.25"{fill}/><circle cx="17.5" cy="5.5" r="2.25"{fill}/><path d="M8.75 17.5h7a3 3 0 0 0 0-6h-7.5a3 3 0 0 1 0-6h7"/>',
|
||||
),
|
||||
person(
|
||||
'<circle cx="12" cy="8.5" r="3.75"{fill}/><path d="M4.75 20c.6-3.6 3.6-5.75 7.25-5.75S18.65 16.4 19.25 20"/>',
|
||||
),
|
||||
globe(
|
||||
'<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17"/><path d="M12 3.5c2.4 2.35 3.5 5.2 3.5 8.5s-1.1 6.15-3.5 8.5c-2.4-2.35-3.5-5.2-3.5-8.5s1.1-6.15 3.5-8.5z"/>',
|
||||
),
|
||||
pin(
|
||||
'<path d="M12 20.5s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"{fill}/><circle cx="12" cy="9.5" r="2.25"/>',
|
||||
),
|
||||
chevronDown('<path d="M7 10l5 5 5-5"/>'),
|
||||
chevronRight('<path d="M10 7l5 5-5 5"/>');
|
||||
|
||||
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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '
|
||||
'stroke="currentColor" stroke-width="$stroke" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">${icon.body.replaceAll('{fill}', fill)}</svg>';
|
||||
return SvgPicture.string(
|
||||
svg,
|
||||
width: size,
|
||||
height: size,
|
||||
theme: SvgTheme(currentColor: color),
|
||||
excludeFromSemantics: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, Locale?>(
|
||||
AppLocaleNotifier.new,
|
||||
);
|
||||
|
||||
class AppLocaleNotifier extends Notifier<Locale?> {
|
||||
@override
|
||||
Locale? build() {
|
||||
final code = ref
|
||||
.watch(sharedPreferencesProvider)
|
||||
.getString(PrefKeys.locale);
|
||||
return code == null ? null : Locale(code);
|
||||
}
|
||||
|
||||
Future<void> select(Locale locale) async {
|
||||
state = locale;
|
||||
await ref
|
||||
.read(sharedPreferencesProvider)
|
||||
.setString(PrefKeys.locale, locale.languageCode);
|
||||
}
|
||||
}
|
||||
@@ -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<GoRouter>((ref) {
|
||||
final rootKey = GlobalKey<NavigatorState>();
|
||||
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;
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SharedPreferences>(
|
||||
(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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
|
||||
/// 버튼·입력창·사진 모서리 반경
|
||||
const double kRadius = 12;
|
||||
|
||||
/// 화면 좌우 여백
|
||||
const double kPagePadding = 20;
|
||||
|
||||
/// 앱 테마 — 흰 바탕, 그림자 없음, 브랜드색 하나.
|
||||
///
|
||||
/// 글꼴은 Pretendard(400·500·600·700, pubspec fonts) 하나만 쓴다.
|
||||
abstract final class AppTheme {
|
||||
static const String kFontFamily = 'Pretendard';
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<String> _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];
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
|
||||
return EventDetail(
|
||||
item: EventItem.fromJson(json),
|
||||
attribution: Attribution.fromJson(
|
||||
json['attribution'] as Map<String, dynamic>,
|
||||
),
|
||||
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<EventItem> items;
|
||||
final bool hasNext;
|
||||
final String? nextCursor;
|
||||
|
||||
factory EventPage.fromJson(Map<String, dynamic> json) {
|
||||
final pageInfo = json['pageInfo'] as Map<String, dynamic>;
|
||||
return EventPage(
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map((e) => EventItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
hasNext: pageInfo['hasNext'] as bool? ?? false,
|
||||
nextCursor: pageInfo['nextCursor'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<EventPage> search({
|
||||
required DateRange range,
|
||||
GeoPoint? near,
|
||||
int? radiusM,
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/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<EventDetail> getEvent(String id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/events/$id');
|
||||
return EventDetail.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
final eventRepositoryProvider = Provider<EventRepository>(
|
||||
(ref) => EventRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
@@ -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 = <Widget>[
|
||||
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],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<EventListPage> createState() => _EventListPageState();
|
||||
}
|
||||
|
||||
class _EventListPageState extends ConsumerState<EventListPage> {
|
||||
final List<EventItem> _items = [];
|
||||
String? _cursor;
|
||||
bool _hasNext = true;
|
||||
bool _loading = false;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMore();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
}
|
||||
@@ -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<EventItem> items;
|
||||
|
||||
/// 조회한 기간 — '전체 보기' 가 같은 기간을 연다
|
||||
final DateRange range;
|
||||
|
||||
/// 주말이 비어 '다가오는 축제·행사' 로 바꿔 보이는지
|
||||
final bool isUpcoming;
|
||||
}
|
||||
|
||||
/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 120km 거리순, 비면 같은 반경 30일
|
||||
final homeEventsProvider = FutureProvider<HomeEvents>((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<EventDetail, String>(
|
||||
(ref, id) => ref.watch(eventRepositoryProvider).getEvent(id),
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <String>[
|
||||
?(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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<GeoPoint?> 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<LocationService>(
|
||||
(ref) => const LocationService(),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, BaseLocationChoice>(
|
||||
BaseLocationNotifier.new,
|
||||
);
|
||||
|
||||
class BaseLocationNotifier extends Notifier<BaseLocationChoice> {
|
||||
@override
|
||||
BaseLocationChoice build() {
|
||||
final saved = ref
|
||||
.watch(sharedPreferencesProvider)
|
||||
.getString(PrefKeys.baseLocation);
|
||||
return BaseLocationChoice.fromStorageKey(saved) ??
|
||||
const BaseLocationChoice.neighborhood(kFallbackNeighborhood);
|
||||
}
|
||||
|
||||
Future<void> select(BaseLocationChoice choice) async {
|
||||
state = choice;
|
||||
await ref
|
||||
.read(sharedPreferencesProvider)
|
||||
.setString(PrefKeys.baseLocation, choice.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하면 [kFallbackNeighborhood] 로 대신한다
|
||||
final basePointProvider = FutureProvider<BasePoint>((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,
|
||||
);
|
||||
});
|
||||
@@ -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<BaseLocationChoice?> showRegionSheet(
|
||||
BuildContext context, {
|
||||
BaseLocationChoice? selected,
|
||||
bool includeCurrent = true,
|
||||
}) {
|
||||
return showModalBottomSheet<BaseLocationChoice>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
_RegionSheet(selected: selected, includeCurrent: includeCurrent),
|
||||
);
|
||||
}
|
||||
|
||||
/// 시트에서 고른 기준 위치를 적용한다. 현재 위치는 권한을 받은 경우에만 바꾼다.
|
||||
Future<void> 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 = <Widget>[
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<OnboardingPage> createState() => _OnboardingPageState();
|
||||
}
|
||||
|
||||
class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
||||
/// 권한 요청 중 — 버튼을 두 번 누르지 못하게 한다
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _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<void> _pickRegion() async {
|
||||
final picked = await showRegionSheet(context, includeCurrent: false);
|
||||
if (picked == null) return;
|
||||
await _finish(picked);
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/storage/preferences.dart';
|
||||
|
||||
/// 첫 실행 화면(위치 권한 안내)을 마쳤는지 — 마치기 전에는 어느 화면이든 첫 실행 화면으로 보낸다
|
||||
final onboardingDoneProvider = NotifierProvider<OnboardingDoneNotifier, bool>(
|
||||
OnboardingDoneNotifier.new,
|
||||
);
|
||||
|
||||
class OnboardingDoneNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() =>
|
||||
ref.watch(sharedPreferencesProvider).getBool(PrefKeys.onboardingDone) ??
|
||||
false;
|
||||
|
||||
Future<void> complete() async {
|
||||
state = true;
|
||||
await ref
|
||||
.read(sharedPreferencesProvider)
|
||||
.setBool(PrefKeys.onboardingDone, true);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "이 화면은 아직 만드는 중이에요."
|
||||
}
|
||||
+16
-51
@@ -1,67 +1,32 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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<void> main() async {
|
||||
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
|
||||
try {
|
||||
await dotenv.load(fileName: '.env');
|
||||
} catch (_) {
|
||||
// .env 파일이 없는 경우 무시 (기본값 사용)
|
||||
}
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
// 번들한 글꼴의 라이선스 — 패키지 라이선스와 함께 라이선스 화면에 보인다(OFL 고지 의무)
|
||||
LicenseRegistry.addLicense(() async* {
|
||||
final text = await rootBundle.loadString('assets/fonts/OFL.txt');
|
||||
yield LicenseEntryWithLineBreaks(const ['Pretendard'], text);
|
||||
});
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> openWebLink(BuildContext context, String url) {
|
||||
final withScheme = RegExp(r'^https?://', caseSensitive: false).hasMatch(url)
|
||||
? url
|
||||
: 'https://$url';
|
||||
return _launch(context, Uri.tryParse(withScheme));
|
||||
}
|
||||
|
||||
/// 전화 앱을 연다 — 숫자와 + 만 남긴다
|
||||
Future<void> 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<void> _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)));
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _openSheet(BuildContext context, WidgetRef ref) async {
|
||||
final current = Localizations.localeOf(context);
|
||||
final picked = await showModalBottomSheet<Locale>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
||||
set(BINARY_NAME "tma_app")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.tma_app")
|
||||
set(APPLICATION_ID "net.wandergo.app")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tma_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tma_app";
|
||||
@@ -399,7 +399,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tma_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tma_app";
|
||||
@@ -413,7 +413,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tma_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/tma_app";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
PRODUCT_NAME = tma_app
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.wandergo.app
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
|
||||
|
||||
+426
-5
@@ -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"
|
||||
|
||||
+30
-2
@@ -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,28 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# 한/영 문구 — lib/l10n/*.arb 에서 생성 (l10n.yaml)
|
||||
generate: true
|
||||
|
||||
# 런타임 환경변수 — flutter_dotenv 로 로드
|
||||
assets:
|
||||
- .env
|
||||
- assets/images/
|
||||
# Pretendard 라이선스(SIL OFL 1.1) — 앱의 오픈소스 라이선스 화면에 함께 보인다
|
||||
- assets/fonts/OFL.txt
|
||||
|
||||
# Pretendard v1.3.9 정적 OTF (github.com/orioncactus/pretendard) — 디자인이 쓰는 굵기만
|
||||
fonts:
|
||||
- family: Pretendard
|
||||
fonts:
|
||||
- asset: assets/fonts/Pretendard-Regular.otf
|
||||
weight: 400
|
||||
- asset: assets/fonts/Pretendard-Medium.otf
|
||||
weight: 500
|
||||
- asset: assets/fonts/Pretendard-SemiBold.otf
|
||||
weight: 600
|
||||
- asset: assets/fonts/Pretendard-Bold.otf
|
||||
weight: 700
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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('홈'), '홈');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<EventItem> weekend;
|
||||
final List<EventItem> upcoming;
|
||||
final List<({DateRange range, GeoPoint? near})> calls = [];
|
||||
|
||||
@override
|
||||
Future<EventPage> 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<EventDetail> 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<bool> requestPermission() async => granted;
|
||||
|
||||
@override
|
||||
Future<GeoPoint?> currentPoint() async =>
|
||||
granted ? (lat: 35.85, lng: 128.6) : null;
|
||||
}
|
||||
|
||||
Future<_FakeEventRepository> _pumpApp(
|
||||
WidgetTester tester, {
|
||||
Map<String, Object> 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="tma_app">
|
||||
<meta name="apple-mobile-web-app-title" content="WanderGo">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>tma_app</title>
|
||||
<title>WanderGo</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tma_app",
|
||||
"short_name": "tma_app",
|
||||
"name": "WanderGo",
|
||||
"short_name": "WanderGo",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
||||
@@ -7,8 +7,14 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
geolocator_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user