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
+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),
),
],
);
}
}