3 Commits

Author SHA1 Message Date
ttipo b6abc3d118 chore(ci): 러너 compose 프로젝트 이름을 러너 이름으로 고정
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m21s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m58s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m22s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m22s
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
프로젝트 이름을 지정하지 않으면 폴더 이름(ci)이 되어, 같은 PC 에서 다른 저장소의
ci/ 러너와 이름이 겹쳐 그 러너를 덮어쓸 수 있다(2026-11-04 arasystem 러너와 같은 PC 에서 확인).
compose 파일에 name: ${RUNNER_NAME} 을 두어 -p 없이도 겹치지 않게 하고, README 에 이름을 다르게 지으라고 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:53:50 +09:00
ttipo 19f3574d97 chore(flutter): 앱 패키지 ID 를 net.wandergo.app 으로 정하고 Pretendard 글꼴 번들
패키지 ID 는 스토어 등록·네이버 지도 Client ID 등록에 쓰이고 출시 후 바꿀 수 없어
템플릿 값(com.example.tma_app)을 보유 도메인(wandergo.net) 기준으로 정했다(2026-11-04 사용자 결정).
Android applicationId·namespace·MainActivity 경로, iOS 번들 ID 를 바꾸고
출시 대상이 아닌 macOS·Linux·웹은 식별자·이름만 맞췄다.

디자인 캔버스의 Pretendard 는 woff2 라 Flutter 가 읽지 못해, 공식 저장소
(github.com/orioncactus/pretendard v1.3.9)의 정적 OTF 중 디자인이 쓰는 400·500·600·700 만 넣었다.
SIL OFL 1.1 원문을 함께 두고 라이선스 화면에 등록했다(고지 조건).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:53:50 +09:00
ttipo 0f306e71ba feat(flutter): 앱 뼈대와 홈 이번 주말 축제·행사 화면
데이터 기반(장소·행사 조회 API)이 생겨 앱 화면을 붙이기 시작한다.
디자인 캔버스의 1~7번(시작·둘러보기) 중 지금 API 로 바로 되는 것부터 만들었다.

- 앱 뼈대: 디자인 캔버스의 색·글자 크기·선 아이콘을 옮긴 테마, go_router 하단 탭
  (홈·지도·내 코스·내 정보 — 지도·내 코스·내 정보는 자리만), 한/영 문구(arb, 4개 언어 전제)
- 첫 실행: 현재 위치를 쓰거나 대구 동네(도심·수성못·앞산 카페거리)를 고른다.
  위치를 못 쓰면 대구 도심을 기준으로 시작하고 알린다. 좌표는 저장하지 않는다
- 홈 '이번 주말 축제·행사': 기준 위치 반경 120km 거리순, 비면 다가오는 30일 (2026-11-03 합의)
- 행사 전체 목록(커서 페이지)·상세(시간·요금·예매·전화·소개·출처)
- 한국어 문단은 단어 단위 줄바꿈(keepAll) — Flutter 기본은 글자 단위로 끊는다
- 공공누리 Type3 사진은 자르지 않고, 꼬리말에 관광공사·KOPIS·문화포털 출처를 적는다
- 한/영 생성물(lib/l10n/generated)은 flutter pub get 이 만들므로 git 에서 뺀다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:51:02 +09:00
73 changed files with 4013 additions and 99 deletions
+4
View File
@@ -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))`
+3
View File
@@ -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 탭에서 실행이 시작됩니다.
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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()`.
+2 -2
View File
@@ -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,4 +1,4 @@
package com.example.tma_app
package net.wandergo.app
import io.flutter.embedding.android.FlutterActivity
+94
View File
@@ -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

+6 -6
View File
@@ -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;
+14 -2
View File
@@ -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>
+7
View File
@@ -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
+26
View File
@@ -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),
);
}
}
+4
View File
@@ -0,0 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 지금 시각 — 테스트에서 날짜를 고정하려고 주입한다
final clockProvider = Provider<DateTime Function()>((ref) => DateTime.now);
+72
View File
@@ -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,
);
}
}
+10
View File
@@ -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);
}
}
+94
View File
@@ -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;
});
+25
View File
@@ -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();
}
+107
View File
@@ -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,
),
],
),
),
),
);
}
}
+23
View File
@@ -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';
}
+49
View File
@@ -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);
}
+165
View File
@@ -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,
),
);
}
+100
View File
@@ -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];
}
+17
View File
@@ -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),
);
}
+33
View File
@@ -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);
}
}
+54
View File
@@ -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."
}
+79
View File
@@ -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
View File
@@ -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'),
),
],
),
),
);
}
}
+29
View File
@@ -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),
),
],
);
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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('홈'), '홈');
});
});
}
+54
View File
@@ -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',
));
});
});
}
+2 -2
View File
@@ -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);
});
});
}
+173 -6
View File
@@ -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);
});
}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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