feat(app): add vworld geocoding and native ads placeholders
This commit is contained in:
@@ -9,6 +9,7 @@ import '../../../domain/entities/visit_record.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/debug_test_data_provider.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import '../../widgets/native_ad_placeholder.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/recommendation_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
@@ -106,129 +107,144 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
_events = _buildEvents(visits, recommendations);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (kDebugMode)
|
||||
const DebugTestDataBanner(
|
||||
margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
// 캘린더
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TableCalendar(
|
||||
firstDay: DateTime.utc(2025, 1, 1),
|
||||
lastDay: DateTime.utc(2030, 12, 31),
|
||||
focusedDay: _focusedDay,
|
||||
calendarFormat: _calendarFormat,
|
||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
||||
onDaySelected: (selectedDay, focusedDay) {
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = focusedDay;
|
||||
});
|
||||
},
|
||||
onFormatChanged: (format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
},
|
||||
eventLoader: _getEventsForDay,
|
||||
calendarBuilders: CalendarBuilders(
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (kDebugMode)
|
||||
const DebugTestDataBanner(
|
||||
margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
color: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TableCalendar(
|
||||
firstDay: DateTime.utc(2025, 1, 1),
|
||||
lastDay: DateTime.utc(2030, 12, 31),
|
||||
focusedDay: _focusedDay,
|
||||
calendarFormat: _calendarFormat,
|
||||
selectedDayPredicate: (day) =>
|
||||
isSameDay(_selectedDay, day),
|
||||
onDaySelected: (selectedDay, focusedDay) {
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = focusedDay;
|
||||
});
|
||||
},
|
||||
onFormatChanged: (format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
},
|
||||
eventLoader: _getEventsForDay,
|
||||
calendarBuilders: CalendarBuilders(
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final calendarEvents = events.cast<_CalendarEvent>();
|
||||
final confirmedVisits = calendarEvents.where(
|
||||
(e) => e.visitRecord?.isConfirmed == true,
|
||||
);
|
||||
final recommendedOnly = calendarEvents.where(
|
||||
(e) => e.recommendationRecord != null,
|
||||
);
|
||||
final calendarEvents = events
|
||||
.cast<_CalendarEvent>();
|
||||
final confirmedVisits = calendarEvents.where(
|
||||
(e) => e.visitRecord?.isConfirmed == true,
|
||||
);
|
||||
final recommendedOnly = calendarEvents.where(
|
||||
(e) => e.recommendationRecord != null,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (confirmedVisits.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (confirmedVisits.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (recommendedOnly.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
outsideDaysVisible: false,
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
if (recommendedOnly.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
outsideDaysVisible: false,
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markersMaxCount: 2,
|
||||
markerDecoration: const BoxDecoration(
|
||||
color: AppColors.lightSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
weekendTextStyle: const TextStyle(
|
||||
color: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonVisible: true,
|
||||
titleCentered: true,
|
||||
formatButtonShowsNext: false,
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
formatButtonTextStyle: const TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
markersMaxCount: 2,
|
||||
markerDecoration: const BoxDecoration(
|
||||
color: AppColors.lightSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
weekendTextStyle: const TextStyle(
|
||||
color: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonVisible: true,
|
||||
titleCentered: true,
|
||||
formatButtonShowsNext: false,
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
formatButtonTextStyle: const TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegend('추천받음', Colors.orange, isDark),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend('방문완료', Colors.green, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const NativeAdPlaceholder(
|
||||
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
),
|
||||
_buildDayRecords(_selectedDay, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 범례
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegend('추천받음', Colors.orange, isDark),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend('방문완료', Colors.green, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 선택된 날짜의 기록
|
||||
Expanded(child: _buildDayRecords(_selectedDay, isDark)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -302,30 +318,35 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
if (event.visitRecord != null) {
|
||||
return VisitRecordCard(
|
||||
visitRecord: event.visitRecord!,
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
if (event.recommendationRecord != null) {
|
||||
return RecommendationRecordCard(
|
||||
recommendation: event.recommendationRecord!,
|
||||
onConfirmVisit: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(event.recommendationRecord!.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
if (event.visitRecord != null) {
|
||||
return VisitRecordCard(
|
||||
visitRecord: event.visitRecord!,
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
if (event.recommendationRecord != null) {
|
||||
return RecommendationRecordCard(
|
||||
recommendation: event.recommendationRecord!,
|
||||
onConfirmVisit: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(event.recommendationRecord!.id);
|
||||
},
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.deleteRecommendation(event.recommendationRecord!.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
class RecommendationRecordCard extends ConsumerWidget {
|
||||
final RecommendationRecord recommendation;
|
||||
final VoidCallback onConfirmVisit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const RecommendationRecordCard({
|
||||
super.key,
|
||||
required this.recommendation,
|
||||
required this.onConfirmVisit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
@@ -43,96 +45,127 @@ class RecommendationRecordCard extends ConsumerWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.whatshot,
|
||||
color: Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(recommendation.recommendationDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onConfirmVisit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('방문 확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.whatshot,
|
||||
color: Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
height: 1,
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(recommendation.recommendationDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onConfirmVisit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('방문 확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart';
|
||||
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||
|
||||
class VisitStatistics extends ConsumerWidget {
|
||||
final DateTime selectedMonth;
|
||||
@@ -42,6 +43,9 @@ class VisitStatistics extends ConsumerWidget {
|
||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const NativeAdPlaceholder(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주간 통계 차트
|
||||
_buildWeeklyChart(weeklyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../providers/location_provider.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../providers/weather_provider.dart';
|
||||
import '../../widgets/native_ad_placeholder.dart';
|
||||
import 'widgets/recommendation_result_dialog.dart';
|
||||
|
||||
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||
@@ -51,64 +52,84 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 맛집 리스트 현황 카드
|
||||
// 상단 요약 바 (높이 최소화)
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.restaurant,
|
||||
size: 20,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final restaurantsAsync = ref.watch(
|
||||
restaurantListProvider,
|
||||
);
|
||||
return restaurantsAsync.when(
|
||||
data: (restaurants) => Text(
|
||||
'${restaurants.length}개',
|
||||
style: AppTypography.heading1(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
error: (_, __) => Text(
|
||||
'0개',
|
||||
style: AppTypography.heading1(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final restaurantsAsync = ref.watch(
|
||||
restaurantListProvider,
|
||||
);
|
||||
return restaurantsAsync.when(
|
||||
data: (restaurants) => Text(
|
||||
'등록된 맛집 ${restaurants.length}개',
|
||||
style: AppTypography.heading2(
|
||||
isDark,
|
||||
).copyWith(fontSize: 18),
|
||||
),
|
||||
loading: () => const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
error: (_, __) => Text(
|
||||
'등록된 맛집 0개',
|
||||
style: AppTypography.heading2(
|
||||
isDark,
|
||||
).copyWith(fontSize: 18),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Text('등록된 맛집', style: AppTypography.body2(isDark)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 날씨 정보 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final weatherAsync = ref.watch(weatherProvider);
|
||||
@@ -164,22 +185,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 카테고리 선택 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('카테고리', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
@@ -204,7 +225,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
.toList();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
runSpacing: 10,
|
||||
children: categories.isEmpty
|
||||
? [const Text('카테고리 없음')]
|
||||
: [
|
||||
@@ -227,22 +248,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 거리 설정 카드
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('최대 거리', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -274,7 +295,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final locationAsync = ref.watch(
|
||||
@@ -322,7 +343,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 추천받기 버튼
|
||||
ElevatedButton(
|
||||
@@ -362,6 +383,11 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const NativeAdPlaceholder(
|
||||
margin: EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -159,6 +159,7 @@ class _ManualRestaurantInputScreenState
|
||||
onFieldChanged: _onFieldChanged,
|
||||
categories: categories,
|
||||
subCategories: subCategories,
|
||||
geocodingStatus: state.geocodingStatus,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../widgets/category_selector.dart';
|
||||
import '../../widgets/native_ad_placeholder.dart';
|
||||
import 'manual_restaurant_input_screen.dart';
|
||||
import 'widgets/restaurant_card.dart';
|
||||
import 'widgets/add_restaurant_dialog.dart';
|
||||
@@ -34,9 +35,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||
final restaurantsAsync = isFiltered
|
||||
? ref.watch(filteredRestaurantsProvider)
|
||||
: ref.watch(sortedRestaurantsByDistanceProvider);
|
||||
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -108,25 +107,56 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
AppLogger.debug(
|
||||
'[restaurant_list_ui] data received, filtered=$isFiltered',
|
||||
);
|
||||
final items = isFiltered
|
||||
? (restaurantsData as List<Restaurant>)
|
||||
.map(
|
||||
(r) => (restaurant: r, distanceKm: null as double?),
|
||||
)
|
||||
.toList()
|
||||
: restaurantsData
|
||||
as List<
|
||||
({Restaurant restaurant, double? distanceKm})
|
||||
>;
|
||||
var items = restaurantsData;
|
||||
|
||||
if (isFiltered) {
|
||||
// 검색 필터
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final lowercaseQuery = searchQuery.toLowerCase();
|
||||
items = items.where((item) {
|
||||
final r = item.restaurant;
|
||||
return r.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(r.description?.toLowerCase().contains(
|
||||
lowercaseQuery,
|
||||
) ??
|
||||
false) ||
|
||||
r.category.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (selectedCategory != null) {
|
||||
items = items.where((item) {
|
||||
final r = item.restaurant;
|
||||
return r.category == selectedCategory ||
|
||||
r.category.contains(selectedCategory) ||
|
||||
CategoryMapper.normalizeNaverCategory(
|
||||
r.category,
|
||||
r.subCategory,
|
||||
) ==
|
||||
selectedCategory ||
|
||||
CategoryMapper.getDisplayName(r.category) ==
|
||||
selectedCategory;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemCount: items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
if (index == 0) {
|
||||
return const NativeAdPlaceholder(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = items[index - 1];
|
||||
return RestaurantCard(
|
||||
restaurant: item.restaurant,
|
||||
distanceKm: item.distanceKm,
|
||||
|
||||
@@ -17,6 +17,7 @@ class AddRestaurantForm extends StatefulWidget {
|
||||
final Function(String) onFieldChanged;
|
||||
final List<String> categories;
|
||||
final List<String> subCategories;
|
||||
final String geocodingStatus;
|
||||
|
||||
const AddRestaurantForm({
|
||||
super.key,
|
||||
@@ -33,6 +34,7 @@ class AddRestaurantForm extends StatefulWidget {
|
||||
required this.onFieldChanged,
|
||||
this.categories = const <String>[],
|
||||
this.subCategories = const <String>[],
|
||||
this.geocodingStatus = '',
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -255,12 +257,28 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.geocodingStatus.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.geocodingStatus,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.blueGrey,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'네이버 지도에서 맛집 정보 가져오기',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row(
|
||||
// children: [
|
||||
// Icon(
|
||||
// Icons.info_outline,
|
||||
// size: 20,
|
||||
// color: isDark
|
||||
// ? AppColors.darkPrimary
|
||||
// : AppColors.lightPrimary,
|
||||
// ),
|
||||
// const SizedBox(width: 8),
|
||||
// Text(
|
||||
// '네이버 지도에서 맛집 정보 가져오기',
|
||||
// style: AppTypography.body1(
|
||||
// isDark,
|
||||
// ).copyWith(fontWeight: FontWeight.bold),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
||||
@@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
// URL 입력 필드
|
||||
TextField(
|
||||
controller: urlController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: '네이버 지도 URL',
|
||||
hintText: kIsWeb
|
||||
@@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
errorText: errorMessage,
|
||||
errorMaxLines: 8,
|
||||
),
|
||||
onSubmitted: (_) => onFetchPressed(),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../services/restaurant_form_validator.dart';
|
||||
|
||||
class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
class FetchedRestaurantJsonView extends StatefulWidget {
|
||||
final bool isDark;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController categoryController;
|
||||
@@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
required this.onFieldChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FetchedRestaurantJsonView> createState() =>
|
||||
_FetchedRestaurantJsonViewState();
|
||||
}
|
||||
|
||||
class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
|
||||
late final FocusNode _categoryFocusNode;
|
||||
late final FocusNode _subCategoryFocusNode;
|
||||
late Set<String> _availableCategories;
|
||||
late Set<String> _availableSubCategories;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_categoryFocusNode = FocusNode();
|
||||
_subCategoryFocusNode = FocusNode();
|
||||
_availableCategories = {
|
||||
'기타',
|
||||
if (widget.categoryController.text.trim().isNotEmpty)
|
||||
widget.categoryController.text.trim(),
|
||||
};
|
||||
_availableSubCategories = {
|
||||
'기타',
|
||||
if (widget.subCategoryController.text.trim().isNotEmpty)
|
||||
widget.subCategoryController.text.trim(),
|
||||
};
|
||||
|
||||
if (widget.categoryController.text.trim().isEmpty) {
|
||||
widget.categoryController.text = '기타';
|
||||
}
|
||||
if (widget.subCategoryController.text.trim().isEmpty) {
|
||||
widget.subCategoryController.text = '기타';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_categoryFocusNode.dispose();
|
||||
_subCategoryFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
color: widget.isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
Text(
|
||||
'가져온 정보',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
widget.isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'{',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'name',
|
||||
controller: nameController,
|
||||
label: '상호',
|
||||
controller: widget.nameController,
|
||||
icon: Icons.store,
|
||||
validator: (value) =>
|
||||
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'category',
|
||||
controller: categoryController,
|
||||
icon: Icons.category,
|
||||
validator: RestaurantFormValidator.validateCategory,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'subCategory',
|
||||
controller: subCategoryController,
|
||||
icon: Icons.label_outline,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'description',
|
||||
controller: descriptionController,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'phoneNumber',
|
||||
controller: phoneController,
|
||||
icon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'roadAddress',
|
||||
controller: roadAddressController,
|
||||
label: '도로명 주소',
|
||||
controller: widget.roadAddressController,
|
||||
icon: Icons.location_on,
|
||||
validator: RestaurantFormValidator.validateAddress,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'jibunAddress',
|
||||
controller: jibunAddressController,
|
||||
label: '지번 주소',
|
||||
controller: widget.jibunAddressController,
|
||||
icon: Icons.map,
|
||||
),
|
||||
_buildCoordinateFields(context),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'naverUrl',
|
||||
controller: naverUrlController,
|
||||
icon: Icons.link,
|
||||
monospace: true,
|
||||
label: '전화번호',
|
||||
controller: widget.phoneController,
|
||||
icon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'}',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildCategoryField(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _buildSubCategoryField(context)),
|
||||
],
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: '설명',
|
||||
controller: widget.descriptionController,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -145,7 +164,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
children: const [
|
||||
Icon(Icons.my_location, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('coordinates'),
|
||||
Text('좌표'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
controller: widget.latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'latitude',
|
||||
labelText: '위도',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '위도를 입력해주세요';
|
||||
@@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
controller: widget.longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'longitude',
|
||||
labelText: '경도',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '경도를 입력해주세요';
|
||||
@@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryField(BuildContext context) {
|
||||
return RawAutocomplete<String>(
|
||||
textEditingController: widget.categoryController,
|
||||
focusNode: _categoryFocusNode,
|
||||
optionsBuilder: (TextEditingValue value) {
|
||||
final query = value.text.trim();
|
||||
if (query.isEmpty) return _availableCategories;
|
||||
|
||||
final lowerQuery = query.toLowerCase();
|
||||
final matches = _availableCategories
|
||||
.where((c) => c.toLowerCase().contains(lowerQuery))
|
||||
.toList();
|
||||
|
||||
final hasExact = _availableCategories.any(
|
||||
(c) => c.toLowerCase() == lowerQuery,
|
||||
);
|
||||
if (!hasExact) {
|
||||
matches.insert(0, query.isEmpty ? '기타' : query);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
displayStringForOption: (option) => option,
|
||||
onSelected: (option) {
|
||||
final normalized = option.trim().isEmpty ? '기타' : option.trim();
|
||||
setState(() {
|
||||
_availableCategories.add(normalized);
|
||||
});
|
||||
widget.categoryController.text = normalized;
|
||||
widget.onFieldChanged(normalized);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return TextFormField(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: '카테고리',
|
||||
hintText: '예: 한식',
|
||||
// prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
onChanged: widget.onFieldChanged,
|
||||
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||
validator: RestaurantFormValidator.validateCategory,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
final isNew = !_availableCategories.contains(option);
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
isNew ? '새 카테고리 추가: $option' : option,
|
||||
style: TextStyle(
|
||||
fontWeight: isNew ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubCategoryField(BuildContext context) {
|
||||
return RawAutocomplete<String>(
|
||||
textEditingController: widget.subCategoryController,
|
||||
focusNode: _subCategoryFocusNode,
|
||||
optionsBuilder: (TextEditingValue value) {
|
||||
final query = value.text.trim();
|
||||
if (query.isEmpty) return _availableSubCategories;
|
||||
|
||||
final lowerQuery = query.toLowerCase();
|
||||
final matches = _availableSubCategories
|
||||
.where((c) => c.toLowerCase().contains(lowerQuery))
|
||||
.toList();
|
||||
|
||||
final hasExact = _availableSubCategories.any(
|
||||
(c) => c.toLowerCase() == lowerQuery,
|
||||
);
|
||||
if (!hasExact) {
|
||||
matches.insert(0, query.isEmpty ? '기타' : query);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
displayStringForOption: (option) => option,
|
||||
onSelected: (option) {
|
||||
final normalized = option.trim().isEmpty ? '기타' : option.trim();
|
||||
setState(() {
|
||||
_availableSubCategories.add(normalized);
|
||||
});
|
||||
widget.subCategoryController.text = normalized;
|
||||
widget.onFieldChanged(normalized);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return TextFormField(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: '세부 카테고리',
|
||||
hintText: '예: 갈비',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
onChanged: widget.onFieldChanged,
|
||||
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
final isNew = !_availableSubCategories.contains(option);
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
isNew ? '새 세부 카테고리 추가: $option' : option,
|
||||
style: TextStyle(
|
||||
fontWeight: isNew ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJsonField(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
@@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: validator,
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
|
||||
: null,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ShareScreen extends ConsumerStatefulWidget {
|
||||
@@ -144,7 +145,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
child: _buildSendSection(isDark),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
const NativeAdPlaceholder(),
|
||||
const SizedBox(height: 16),
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.download_rounded,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/services/permission_service.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@@ -178,13 +180,26 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
}
|
||||
|
||||
void _navigateToHome() {
|
||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
||||
Future.wait([
|
||||
_ensurePermissions(),
|
||||
Future.delayed(AppConstants.splashAnimationDuration),
|
||||
]).then((_) {
|
||||
if (mounted) {
|
||||
context.go('/home');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensurePermissions() async {
|
||||
try {
|
||||
await Permission.notification.request();
|
||||
await Permission.location.request();
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
} catch (_) {
|
||||
// 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _foodControllers) {
|
||||
|
||||
Reference in New Issue
Block a user