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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user