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>
This commit is contained in:
2026-11-04 14:51:02 +09:00
parent 170be20b6d
commit 0f306e71ba
59 changed files with 3876 additions and 83 deletions
+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);
}
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:wandergo/core/theme/app_colors.dart';
/// 버튼·입력창·사진 모서리 반경
const double kRadius = 12;
/// 화면 좌우 여백
const double kPagePadding = 20;
/// 앱 테마 — 흰 바탕, 그림자 없음, 브랜드색 하나.
///
/// 글꼴은 Pretendard 를 쓰기로 했다. 글꼴 파일을 받기 전까지는 기기 기본 글꼴로 보인다
/// ([kFontFamily] 가 null).
abstract final class AppTheme {
/// Pretendard 를 pubspec fonts 에 등록하면 'Pretendard' 로 바꾼다.
static const String? kFontFamily = null;
static ThemeData light() {
const colorScheme = ColorScheme.light(
primary: AppColors.brand,
onPrimary: Colors.white,
secondary: AppColors.brand,
onSecondary: Colors.white,
surface: AppColors.background,
onSurface: AppColors.text,
onSurfaceVariant: AppColors.textTertiary,
outline: AppColors.border,
outlineVariant: AppColors.divider,
);
// 글자 스타일이 들어가는 곳(상단 제목·버튼)은 base 의 textTheme 에서 파생한다.
// 따로 TextStyle 을 만들면 글꼴 이름이 빠져 테마 글꼴과 어긋난다.
final base = ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: kFontFamily,
scaffoldBackgroundColor: AppColors.background,
textTheme: _textTheme,
dividerTheme: const DividerThemeData(
color: AppColors.divider,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.inputFill,
hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 15),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(kRadius),
borderSide: BorderSide.none,
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: AppColors.background,
surfaceTintColor: Colors.transparent,
showDragHandle: true,
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
);
final text = base.textTheme;
final buttonShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
);
return base.copyWith(
appBarTheme: AppBarTheme(
backgroundColor: AppColors.background,
foregroundColor: AppColors.text,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: text.titleSmall?.copyWith(
fontSize: 17,
height: 24 / 17,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.brand,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
shape: buttonShape,
textStyle: text.labelLarge?.copyWith(fontSize: 15),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.text,
minimumSize: const Size(44, 44),
side: const BorderSide(color: AppColors.border),
shape: buttonShape,
textStyle: text.labelLarge?.copyWith(fontSize: 14),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.textTertiary,
minimumSize: const Size(44, 44),
textStyle: text.labelLarge?.copyWith(fontSize: 14),
),
),
);
}
/// 디자인 캔버스의 글자 크기·줄 높이
static const TextTheme _textTheme = TextTheme(
// 첫 실행 제목 26/36
headlineSmall: TextStyle(
fontSize: 26,
height: 36 / 26,
fontWeight: FontWeight.w700,
color: AppColors.text,
),
// 홈 제목·상세 제목 22/30
titleLarge: TextStyle(
fontSize: 22,
height: 30 / 22,
fontWeight: FontWeight.w700,
color: AppColors.text,
),
// 섹션 제목 18/26
titleMedium: TextStyle(
fontSize: 18,
height: 26 / 18,
fontWeight: FontWeight.w700,
color: AppColors.text,
),
// 목록 이름 15/21
titleSmall: TextStyle(
fontSize: 15,
height: 21 / 15,
fontWeight: FontWeight.w600,
color: AppColors.text,
),
// 설명 문단 15/23
bodyLarge: TextStyle(
fontSize: 15,
height: 23 / 15,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
// 보조 설명 13/19
bodyMedium: TextStyle(
fontSize: 13,
height: 19 / 13,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
// 메타 정보 12/16
bodySmall: TextStyle(
fontSize: 12,
height: 16 / 12,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
// 버튼·칩 글자 13
labelLarge: TextStyle(
fontSize: 13,
height: 18 / 13,
fontWeight: FontWeight.w600,
color: AppColors.text,
),
);
}
+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": "이 화면은 아직 만드는 중이에요."
}
+9 -51
View File
@@ -1,67 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wandergo/app.dart';
import 'package:wandergo/core/storage/preferences.dart';
Future<void> main() async {
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
WidgetsFlutterBinding.ensureInitialized();
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
try {
await dotenv.load(fileName: '.env');
} catch (_) {
// .env 파일이 없는 경우 무시 (기본값 사용)
}
final preferences = await SharedPreferences.getInstance();
runApp(
// Riverpod Provider Scope 적용
const ProviderScope(
child: MyApp(),
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(preferences)],
child: const WanderGoApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TMA App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomeView(),
);
}
}
class HomeView extends StatelessWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TMA Global Architecture'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Riverpod & Dio Interceptor Setup Complete.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')),
);
},
child: const Text('Show Global Alert Test'),
),
],
),
),
);
}
}
+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),
),
],
);
}
}