generated from ttipo/project-base
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:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user