feat(app): seed restaurants, geocode addresses, refresh sharing

This commit is contained in:
JiWoong Sul
2025-11-26 19:01:00 +09:00
parent 2a01fa50c6
commit 0e8c06bade
29 changed files with 18319 additions and 427 deletions

View File

@@ -217,28 +217,31 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
Consumer(
builder: (context, ref, child) {
final locationAsync = ref.watch(
currentLocationProvider,
currentLocationWithFallbackProvider,
);
final restaurantsAsync = ref.watch(
restaurantListProvider,
);
if (locationAsync.hasValue &&
restaurantsAsync.hasValue) {
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
final location = locationAsync.maybeWhen(
data: (pos) => pos,
orElse: () => null,
);
final restaurants = restaurantsAsync.maybeWhen(
data: (list) => list,
orElse: () => null,
);
if (location != null && restaurants != null) {
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
);
return Text(
'$count개 맛집 포함',
style: AppTypography.caption(isDark),
);
}
if (location != null && restaurants != null) {
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
);
return Text(
'$count개 맛집 포함',
style: AppTypography.caption(isDark),
);
}
return Text(
@@ -439,15 +442,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
}
bool _canRecommend() {
final locationAsync = ref.read(currentLocationProvider);
final locationAsync = ref.read(currentLocationWithFallbackProvider);
final restaurantsAsync = ref.read(restaurantListProvider);
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) {
return false;
}
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
final location = locationAsync.maybeWhen(
data: (pos) => pos,
orElse: () => null,
);
final restaurants = restaurantsAsync.maybeWhen(
data: (list) => list,
orElse: () => null,
);
if (location == null || restaurants == null || restaurants.isEmpty) {
return false;
@@ -491,10 +496,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
final adWatched = await adService.showInterstitialAd(context);
if (!mounted) return;
if (!adWatched) {
_showSnack(
'광고를 끝까지 시청해야 추천을 받을 수 있어요.',
backgroundColor: AppColors.lightError,
);
_showSnack('광고를 끝까지 시청해야 추천을 받을 수 있어요.', type: _SnackType.error);
return;
}
}
@@ -502,10 +504,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
if (!mounted) return;
await _showRecommendationDialog(candidate, recommendedAt: recommendedAt);
} catch (_) {
_showSnack(
'추천을 준비하는 중 문제가 발생했습니다.',
backgroundColor: AppColors.lightError,
);
_showSnack('추천을 준비하는 중 문제가 발생했습니다.', type: _SnackType.error);
} finally {
if (mounted) {
setState(() {
@@ -531,17 +530,14 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
if (result.hasError) {
final message = result.error?.toString() ?? '알 수 없는 오류';
_showSnack(
'추천 중 오류가 발생했습니다: $message',
backgroundColor: AppColors.lightError,
);
_showSnack('추천 중 오류가 발생했습니다: $message', type: _SnackType.error);
return null;
}
if (recommendation == null) {
_showSnack(
'조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.',
backgroundColor: AppColors.lightError,
type: _SnackType.warning,
);
}
return recommendation;
@@ -626,10 +622,10 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
_showSnack(
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
backgroundColor: AppColors.lightError,
type: _SnackType.warning,
);
} else {
_showSnack('맛있게 드세요! 🍴');
_showSnack('맛있게 드세요! 🍴', type: _SnackType.success);
}
if (mounted) {
setState(() {
@@ -637,25 +633,25 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
});
}
} catch (_) {
_showSnack(
'방문 기록 또는 알림 예약에 실패했습니다.',
backgroundColor: AppColors.lightError,
);
_showSnack('방문 기록 또는 알림 예약에 실패했습니다.', type: _SnackType.error);
}
}
void _showSnack(
String message, {
Color backgroundColor = AppColors.lightPrimary,
}) {
void _showSnack(String message, {_SnackType type = _SnackType.info}) {
if (!mounted) return;
final bgColor = switch (type) {
_SnackType.success => Colors.teal.shade600,
_SnackType.warning => Colors.orange.shade600,
_SnackType.error => AppColors.lightError,
_SnackType.info => Colors.blueGrey.shade600,
};
final topInset = MediaQuery.of(context).viewPadding.top;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
backgroundColor: bgColor,
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.fromLTRB(
16,
@@ -668,3 +664,5 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
);
}
}
enum _SnackType { info, warning, error, success }

View File

@@ -1,5 +1,6 @@
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 '../../providers/restaurant_provider.dart';
@@ -31,10 +32,11 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
final isDark = Theme.of(context).brightness == Brightness.dark;
final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
final restaurantsAsync = ref.watch(
searchQuery.isNotEmpty || selectedCategory != null
isFiltered
? filteredRestaurantsProvider
: restaurantListProvider,
: sortedRestaurantsByDistanceProvider,
);
return Scaffold(
@@ -103,15 +105,30 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
// 맛집 목록
Expanded(
child: restaurantsAsync.when(
data: (restaurants) {
if (restaurants.isEmpty) {
data: (restaurantsData) {
final items = isFiltered
? (restaurantsData as List<Restaurant>)
.map(
(r) => (restaurant: r, distanceKm: null as double?),
)
.toList()
: restaurantsData
as List<
({Restaurant restaurant, double? distanceKm})
>;
if (items.isEmpty) {
return _buildEmptyState(isDark);
}
return ListView.builder(
itemCount: restaurants.length,
itemCount: items.length,
itemBuilder: (context, index) {
return RestaurantCard(restaurant: restaurants[index]);
final item = items[index];
return RestaurantCard(
restaurant: item.restaurant,
distanceKm: item.distanceKm,
);
},
);
},
@@ -241,25 +258,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
_addByNaverLink();
},
),
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.search,
color: AppColors.lightPrimary,
),
),
title: const Text('상호명으로 검색'),
subtitle: const Text('가게 이름으로 검색하여 추가'),
onTap: () {
Navigator.pop(context);
_addBySearch();
},
),
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
@@ -292,14 +290,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
);
}
Future<void> _addBySearch() {
return showDialog(
context: context,
builder: (context) =>
const AddRestaurantDialog(mode: AddRestaurantDialogMode.search),
);
}
Future<void> _addManually() async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),

View File

@@ -223,7 +223,7 @@ class AddRestaurantForm extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),

View File

@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
import 'package:lunchpick/presentation/providers/di_providers.dart';
import 'package:lunchpick/presentation/providers/location_provider.dart';
import 'add_restaurant_form.dart';
/// 기존 맛집 정보를 편집하는 다이얼로그
class EditRestaurantDialog extends ConsumerStatefulWidget {
final Restaurant restaurant;
const EditRestaurantDialog({super.key, required this.restaurant});
@override
ConsumerState<EditRestaurantDialog> createState() =>
_EditRestaurantDialogState();
}
class _EditRestaurantDialogState extends ConsumerState<EditRestaurantDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _categoryController;
late final TextEditingController _subCategoryController;
late final TextEditingController _descriptionController;
late final TextEditingController _phoneController;
late final TextEditingController _roadAddressController;
late final TextEditingController _jibunAddressController;
late final TextEditingController _latitudeController;
late final TextEditingController _longitudeController;
bool _isSaving = false;
late final String _originalRoadAddress;
late final String _originalJibunAddress;
@override
void initState() {
super.initState();
final restaurant = widget.restaurant;
_nameController = TextEditingController(text: restaurant.name);
_categoryController = TextEditingController(text: restaurant.category);
_subCategoryController = TextEditingController(
text: restaurant.subCategory,
);
_descriptionController = TextEditingController(
text: restaurant.description ?? '',
);
_phoneController = TextEditingController(
text: restaurant.phoneNumber ?? '',
);
_roadAddressController = TextEditingController(
text: restaurant.roadAddress,
);
_jibunAddressController = TextEditingController(
text: restaurant.jibunAddress,
);
_latitudeController = TextEditingController(
text: restaurant.latitude.toString(),
);
_longitudeController = TextEditingController(
text: restaurant.longitude.toString(),
);
_originalRoadAddress = restaurant.roadAddress;
_originalJibunAddress = restaurant.jibunAddress;
}
@override
void dispose() {
_nameController.dispose();
_categoryController.dispose();
_subCategoryController.dispose();
_descriptionController.dispose();
_phoneController.dispose();
_roadAddressController.dispose();
_jibunAddressController.dispose();
_latitudeController.dispose();
_longitudeController.dispose();
super.dispose();
}
void _onFieldChanged(String _) {
setState(() {});
}
Future<void> _save() async {
if (_formKey.currentState?.validate() != true) {
return;
}
setState(() => _isSaving = true);
final addressChanged =
_roadAddressController.text.trim() != _originalRoadAddress ||
_jibunAddressController.text.trim() != _originalJibunAddress;
final coords = await _resolveCoordinates(
latitudeText: _latitudeController.text.trim(),
longitudeText: _longitudeController.text.trim(),
roadAddress: _roadAddressController.text.trim(),
jibunAddress: _jibunAddressController.text.trim(),
forceRecalculate: addressChanged,
);
_latitudeController.text = coords.latitude.toString();
_longitudeController.text = coords.longitude.toString();
final updatedRestaurant = widget.restaurant.copyWith(
name: _nameController.text.trim(),
category: _categoryController.text.trim(),
subCategory: _subCategoryController.text.trim().isEmpty
? _categoryController.text.trim()
: _subCategoryController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
phoneNumber: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
roadAddress: _roadAddressController.text.trim(),
jibunAddress: _jibunAddressController.text.trim().isEmpty
? _roadAddressController.text.trim()
: _jibunAddressController.text.trim(),
latitude: coords.latitude,
longitude: coords.longitude,
updatedAt: DateTime.now(),
needsAddressVerification: coords.usedCurrentLocation,
);
try {
await ref
.read(restaurantNotifierProvider.notifier)
.updateRestaurant(updatedRestaurant);
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop(true);
messenger.showSnackBar(
SnackBar(
content: Row(
children: const [
Icon(Icons.check_circle, color: Colors.white, size: 20),
SizedBox(width: 8),
Text('맛집 정보가 업데이트되었습니다'),
],
),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('수정에 실패했습니다: $e'),
backgroundColor: AppColors.lightError,
),
);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'맛집 정보 수정',
style: AppTypography.heading1(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
AddRestaurantForm(
formKey: _formKey,
nameController: _nameController,
categoryController: _categoryController,
subCategoryController: _subCategoryController,
descriptionController: _descriptionController,
phoneController: _phoneController,
roadAddressController: _roadAddressController,
jibunAddressController: _jibunAddressController,
latitudeController: _latitudeController,
longitudeController: _longitudeController,
onFieldChanged: _onFieldChanged,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSaving
? null
: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Text('저장'),
),
],
),
],
),
),
),
);
}
Future<({double latitude, double longitude, bool usedCurrentLocation})>
_resolveCoordinates({
required String latitudeText,
required String longitudeText,
required String roadAddress,
required String jibunAddress,
bool forceRecalculate = false,
}) async {
if (!forceRecalculate) {
final parsedLat = double.tryParse(latitudeText);
final parsedLon = double.tryParse(longitudeText);
if (parsedLat != null && parsedLon != null) {
return (
latitude: parsedLat,
longitude: parsedLon,
usedCurrentLocation: false,
);
}
}
final geocodingService = ref.read(geocodingServiceProvider);
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
if (address.isNotEmpty) {
final result = await geocodingService.geocode(address);
if (result != null) {
return (
latitude: result.latitude,
longitude: result.longitude,
usedCurrentLocation: false,
);
}
}
try {
final position = await ref.read(currentLocationProvider.future);
if (position != null) {
return (
latitude: position.latitude,
longitude: position.longitude,
usedCurrentLocation: true,
);
}
} catch (_) {}
final fallback = geocodingService.defaultCoordinates();
return (
latitude: fallback.latitude,
longitude: fallback.longitude,
usedCurrentLocation: true,
);
}
}

View File

@@ -5,11 +5,13 @@ import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
import 'package:lunchpick/presentation/providers/visit_provider.dart';
import 'edit_restaurant_dialog.dart';
class RestaurantCard extends ConsumerWidget {
final Restaurant restaurant;
final double? distanceKm;
const RestaurantCard({super.key, required this.restaurant});
const RestaurantCard({super.key, required this.restaurant, this.distanceKm});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -49,41 +51,94 @@ class RestaurantCard extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: AppTypography.heading2(isDark),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Text(
restaurant.category,
style: AppTypography.body2(isDark),
),
if (restaurant.subCategory !=
restaurant.category) ...[
Text('', style: AppTypography.body2(isDark)),
Text(
restaurant.subCategory,
style: AppTypography.body2(isDark),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: AppTypography.heading2(isDark),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Text(
restaurant.category,
style: AppTypography.body2(isDark),
),
if (restaurant.subCategory !=
restaurant.category) ...[
Text(
'',
style: AppTypography.body2(isDark),
),
Text(
restaurant.subCategory,
style: AppTypography.body2(isDark),
),
],
],
),
],
),
],
),
],
),
],
),
),
// 더보기 버튼
IconButton(
icon: Icon(
Icons.more_vert,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
onPressed: () => _showOptions(context, ref, isDark),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_BadgesRow(
distanceKm: distanceKm,
needsAddressVerification:
restaurant.needsAddressVerification,
isDark: isDark,
),
const SizedBox(height: 8),
// 더보기 버튼
PopupMenuButton<_RestaurantMenuAction>(
icon: Icon(
Icons.more_vert,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
offset: const Offset(0, 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (action) =>
_handleMenuAction(action, context, ref),
itemBuilder: (context) => [
const PopupMenuItem(
value: _RestaurantMenuAction.edit,
child: Row(
children: [
Icon(Icons.edit, color: AppColors.lightPrimary),
SizedBox(width: 8),
Text('수정'),
],
),
),
const PopupMenuItem(
value: _RestaurantMenuAction.delete,
child: Row(
children: [
Icon(Icons.delete, color: AppColors.lightError),
SizedBox(width: 8),
Text('삭제'),
],
),
),
],
),
],
),
],
),
@@ -240,75 +295,172 @@ class RestaurantCard extends ConsumerWidget {
);
}
void _showOptions(BuildContext context, WidgetRef ref, bool isDark) {
showModalBottomSheet(
context: context,
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
borderRadius: BorderRadius.circular(2),
void _handleMenuAction(
_RestaurantMenuAction action,
BuildContext context,
WidgetRef ref,
) async {
switch (action) {
case _RestaurantMenuAction.edit:
await showDialog<bool>(
context: context,
builder: (context) => EditRestaurantDialog(restaurant: restaurant),
);
break;
case _RestaurantMenuAction.delete:
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('맛집 삭제'),
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'삭제',
style: TextStyle(color: AppColors.lightError),
),
),
ListTile(
leading: const Icon(Icons.edit, color: AppColors.lightPrimary),
title: const Text('수정'),
onTap: () {
Navigator.pop(context);
// TODO: 수정 기능 구현
},
),
ListTile(
leading: const Icon(Icons.delete, color: AppColors.lightError),
title: const Text('삭제'),
onTap: () async {
Navigator.pop(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('맛집 삭제'),
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'삭제',
style: TextStyle(color: AppColors.lightError),
),
),
],
),
);
if (confirmed == true) {
await ref
.read(restaurantNotifierProvider.notifier)
.deleteRestaurant(restaurant.id);
}
},
),
const SizedBox(height: 8),
],
),
);
},
if (confirmed == true) {
await ref
.read(restaurantNotifierProvider.notifier)
.deleteRestaurant(restaurant.id);
}
break;
}
}
}
class _DistanceBadge extends StatelessWidget {
final double distanceKm;
final bool isDark;
const _DistanceBadge({required this.distanceKm, required this.isDark});
@override
Widget build(BuildContext context) {
final text = _formatDistance(distanceKm);
final isFar = distanceKm * 1000 >= 2000;
final Color bgColor;
final Color textColor;
if (isFar) {
bgColor = isDark
? AppColors.darkError.withOpacity(0.15)
: AppColors.lightError.withOpacity(0.15);
textColor = AppColors.lightError;
} else {
bgColor = isDark
? AppColors.darkPrimary.withOpacity(0.12)
: AppColors.lightPrimary.withOpacity(0.12);
textColor = AppColors.lightPrimary;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.place, size: 16, color: textColor),
const SizedBox(width: 4),
Text(
text,
style: AppTypography.caption(
isDark,
).copyWith(color: textColor, fontWeight: FontWeight.w600),
),
],
),
);
}
String _formatDistance(double distanceKm) {
final meters = distanceKm * 1000;
if (meters >= 2000) {
return '2.0km 이상';
}
if (meters >= 1000) {
return '${distanceKm.toStringAsFixed(1)}km';
}
return '${meters.round()}m';
}
}
enum _RestaurantMenuAction { edit, delete }
class _BadgesRow extends StatelessWidget {
final double? distanceKm;
final bool needsAddressVerification;
final bool isDark;
const _BadgesRow({
required this.distanceKm,
required this.needsAddressVerification,
required this.isDark,
});
@override
Widget build(BuildContext context) {
final badges = <Widget>[];
if (needsAddressVerification) {
badges.add(_AddressVerificationChip(isDark: isDark));
}
if (distanceKm != null) {
badges.add(_DistanceBadge(distanceKm: distanceKm!, isDark: isDark));
}
if (badges.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 4,
alignment: WrapAlignment.end,
children: badges,
);
}
}
class _AddressVerificationChip extends StatelessWidget {
final bool isDark;
const _AddressVerificationChip({required this.isDark});
@override
Widget build(BuildContext context) {
final bgColor = isDark
? AppColors.darkError.withOpacity(0.12)
: AppColors.lightError.withOpacity(0.12);
final textColor = isDark ? AppColors.darkError : AppColors.lightError;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 16, color: textColor),
const SizedBox(width: 4),
Text(
'주소확인',
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
],
),
);
}
}

View File

@@ -21,6 +21,63 @@ class ShareScreen extends ConsumerStatefulWidget {
ConsumerState<ShareScreen> createState() => _ShareScreenState();
}
class _ShareCard extends StatelessWidget {
final bool isDark;
final IconData icon;
final Color iconColor;
final Color iconBgColor;
final String title;
final String subtitle;
final Widget child;
const _ShareCard({
required this.isDark,
required this.icon,
required this.iconColor,
required this.iconBgColor,
required this.title,
required this.subtitle,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: iconBgColor,
shape: BoxShape.circle,
),
child: Icon(icon, size: 48, color: iconColor),
),
const SizedBox(height: 16),
Text(title, style: AppTypography.heading2(isDark)),
const SizedBox(height: 8),
Text(
subtitle,
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
child,
],
),
),
),
);
}
}
class _ShareScreenState extends ConsumerState<ShareScreen> {
String? _shareCode;
bool _isScanning = false;
@@ -62,233 +119,180 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 공유받기 섹션
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.download_rounded,
size: 48,
color: AppColors.lightPrimary,
),
),
const SizedBox(height: 16),
Text('리스트 공유받기', style: AppTypography.heading2(isDark)),
const SizedBox(height: 8),
Text(
'다른 사람의 맛집 리스트를 받아보세요',
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (_shareCode != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.lightPrimary.withOpacity(0.3),
width: 2,
),
),
child: Text(
_shareCode!,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 6,
color: AppColors.lightPrimary,
),
),
),
const SizedBox(height: 12),
Text(
'이 코드를 상대방에게 알려주세요',
style: AppTypography.caption(isDark),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_shareCode = null;
});
ref.read(bluetoothServiceProvider).stopListening();
},
icon: const Icon(Icons.close),
label: const Text('취소'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightError,
),
),
] else
ElevatedButton.icon(
onPressed: () {
_generateShareCode();
},
icon: const Icon(Icons.qr_code),
label: const Text('공유 코드 생성'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ShareCard(
isDark: isDark,
icon: Icons.upload_rounded,
iconColor: AppColors.lightSecondary,
iconBgColor: AppColors.lightSecondary.withOpacity(0.1),
title: '내 리스트 공유하기',
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
child: _buildSendSection(isDark),
),
),
),
const SizedBox(height: 16),
// 공유하기 섹션
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightSecondary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.upload_rounded,
size: 48,
color: AppColors.lightSecondary,
),
),
const SizedBox(height: 16),
Text('내 리스트 공유하기', style: AppTypography.heading2(isDark)),
const SizedBox(height: 8),
Text(
'내 맛집 리스트를 다른 사람과 공유하세요',
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (_isScanning && _nearbyDevices != null) ...[
Container(
constraints: const BoxConstraints(maxHeight: 220),
child: _nearbyDevices!.isEmpty
? Column(
children: [
const CircularProgressIndicator(
color: AppColors.lightSecondary,
),
const SizedBox(height: 16),
Text(
'주변 기기를 검색 중...',
style: AppTypography.caption(isDark),
),
],
)
: ListView.builder(
itemCount: _nearbyDevices!.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final device = _nearbyDevices![index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(
Icons.phone_android,
color: AppColors.lightSecondary,
),
title: Text(
device.code,
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
subtitle: Text(
'기기 ID: ${device.deviceId}',
),
trailing: const Icon(
Icons.send,
color: AppColors.lightSecondary,
),
onTap: () {
_sendList(device.code);
},
),
);
},
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_isScanning = false;
_nearbyDevices = null;
});
},
icon: const Icon(Icons.stop),
label: const Text('스캔 중지'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightError,
),
),
] else
ElevatedButton.icon(
onPressed: () {
_scanDevices();
},
icon: const Icon(Icons.radar),
label: const Text('주변 기기 스캔'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightSecondary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
const SizedBox(height: 20),
_ShareCard(
isDark: isDark,
icon: Icons.download_rounded,
iconColor: AppColors.lightPrimary,
iconBgColor: AppColors.lightPrimary.withOpacity(0.1),
title: '리스트 공유받기',
subtitle: '다른 사람의 맛집 리스트를 받아보세요',
child: _buildReceiveSection(isDark),
),
),
],
),
],
),
),
),
);
}
Widget _buildReceiveSection(bool isDark) {
return Column(
children: [
if (_shareCode != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.lightPrimary.withOpacity(0.3),
width: 2,
),
),
child: Text(
_shareCode!,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 6,
color: AppColors.lightPrimary,
),
),
),
const SizedBox(height: 12),
Text('이 코드를 상대방에게 알려주세요', style: AppTypography.caption(isDark)),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_shareCode = null;
});
ref.read(bluetoothServiceProvider).stopListening();
},
icon: const Icon(Icons.close),
label: const Text('취소'),
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
),
] else
ElevatedButton.icon(
onPressed: () {
_generateShareCode();
},
icon: const Icon(Icons.bluetooth),
label: const Text('공유 코드 생성'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
);
}
Widget _buildSendSection(bool isDark) {
return Column(
children: [
if (_isScanning && _nearbyDevices != null) ...[
Container(
constraints: const BoxConstraints(maxHeight: 220),
child: _nearbyDevices!.isEmpty
? Column(
children: [
const CircularProgressIndicator(
color: AppColors.lightSecondary,
),
const SizedBox(height: 16),
Text(
'주변 기기를 검색 중...',
style: AppTypography.caption(isDark),
),
],
)
: ListView.builder(
itemCount: _nearbyDevices!.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final device = _nearbyDevices![index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(
Icons.phone_android,
color: AppColors.lightSecondary,
),
title: Text(
device.code,
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
subtitle: Text('기기 ID: ${device.deviceId}'),
trailing: const Icon(
Icons.send,
color: AppColors.lightSecondary,
),
onTap: () {
_sendList(device.code);
},
),
);
},
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_isScanning = false;
_nearbyDevices = null;
});
},
icon: const Icon(Icons.stop),
label: const Text('스캔 중지'),
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
),
] else
ElevatedButton.icon(
onPressed: () {
_scanDevices();
},
icon: const Icon(Icons.radar),
label: const Text('주변 기기 스캔'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightSecondary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
);
}
Future<void> _generateShareCode() async {
final hasPermission =
await PermissionService.checkAndRequestBluetoothPermission();

View File

@@ -12,6 +12,7 @@ import 'package:lunchpick/domain/repositories/visit_repository.dart';
import 'package:lunchpick/domain/repositories/settings_repository.dart';
import 'package:lunchpick/domain/repositories/weather_repository.dart';
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
import 'package:lunchpick/core/services/geocoding_service.dart';
/// RestaurantRepository Provider
final restaurantRepositoryProvider = Provider<RestaurantRepository>((ref) {
@@ -57,3 +58,8 @@ final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
final parser = ref.watch(naverMapParserProvider);
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
});
/// GeocodingService Provider
final geocodingServiceProvider = Provider<GeocodingService>((ref) {
return GeocodingService();
});

View File

@@ -78,6 +78,28 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
);
});
/// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고,
/// 이후 실제 위치 스트림이 들어오면 업데이트하는 Provider.
final currentLocationWithFallbackProvider = StreamProvider<Position>((
ref,
) async* {
final initial = await Future.any([
ref
.watch(currentLocationProvider.future)
.then((pos) => pos ?? defaultPosition()),
Future<Position>.delayed(
const Duration(seconds: 3),
() => defaultPosition(),
),
]).catchError((_) => defaultPosition());
yield initial;
yield* ref.watch(locationStreamProvider.stream).handleError((_) {
// 스트림 오류는 무시하고 마지막 위치를 유지
});
});
/// 위치 관리 StateNotifier
class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
LocationNotifier() : super(const AsyncValue.loading());

View File

@@ -3,6 +3,8 @@ import 'package:lunchpick/core/utils/category_mapper.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
import 'package:lunchpick/presentation/providers/di_providers.dart';
import 'package:lunchpick/presentation/providers/location_provider.dart';
import 'package:lunchpick/core/utils/distance_calculator.dart';
import 'package:uuid/uuid.dart';
/// 맛집 목록 Provider
@@ -11,6 +13,35 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
return repository.watchRestaurants();
});
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
final sortedRestaurantsByDistanceProvider =
StreamProvider<List<({Restaurant restaurant, double? distanceKm})>>((ref) {
final restaurantsStream = ref.watch(restaurantListProvider.stream);
final positionAsync = ref.watch(currentLocationProvider);
final position = positionAsync.maybeWhen(
data: (pos) => pos ?? defaultPosition(),
orElse: () => defaultPosition(),
);
return restaurantsStream.map((restaurants) {
final sorted =
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
final distanceKm = DistanceCalculator.calculateDistance(
lat1: position.latitude,
lon1: position.longitude,
lat2: r.latitude,
lon2: r.longitude,
);
return (restaurant: r, distanceKm: distanceKm);
}).toList()..sort(
(a, b) => (a.distanceKm ?? double.infinity).compareTo(
b.distanceKm ?? double.infinity,
),
);
return sorted;
});
});
/// 특정 맛집 Provider
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
ref,
@@ -20,10 +51,14 @@ final restaurantProvider = FutureProvider.family<Restaurant?, String>((
return repository.getRestaurantById(id);
});
/// 카테고리 목록 Provider
final categoriesProvider = FutureProvider<List<String>>((ref) async {
final repository = ref.watch(restaurantRepositoryProvider);
return repository.getAllCategories();
/// 카테고리 목록 Provider (맛집 스트림을 구독해 즉시 갱신)
final categoriesProvider = StreamProvider<List<String>>((ref) {
final restaurantsStream = ref.watch(restaurantListProvider.stream);
return restaurantsStream.map((restaurants) {
final categories = restaurants.map((r) => r.category).toSet().toList()
..sort();
return categories;
});
});
/// 맛집 관리 StateNotifier
@@ -76,24 +111,12 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
state = const AsyncValue.loading();
try {
final updated = Restaurant(
id: restaurant.id,
name: restaurant.name,
category: restaurant.category,
subCategory: restaurant.subCategory,
description: restaurant.description,
phoneNumber: restaurant.phoneNumber,
roadAddress: restaurant.roadAddress,
jibunAddress: restaurant.jibunAddress,
latitude: restaurant.latitude,
longitude: restaurant.longitude,
lastVisitDate: restaurant.lastVisitDate,
source: restaurant.source,
createdAt: restaurant.createdAt,
updatedAt: DateTime.now(),
final nextSource = restaurant.source == DataSource.PRESET
? DataSource.USER_INPUT
: restaurant.source;
await _repository.updateRestaurant(
restaurant.copyWith(source: nextSource, updatedAt: DateTime.now()),
);
await _repository.updateRestaurant(updated);
state = const AsyncValue.data(null);
} catch (e, stack) {
state = AsyncValue.error(e, stack);

View File

@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
import '../../domain/entities/restaurant.dart';
import '../providers/di_providers.dart';
import '../providers/restaurant_provider.dart';
import '../providers/location_provider.dart';
/// 식당 추가 화면의 상태 모델
class AddRestaurantState {
@@ -248,6 +249,15 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
// 네이버에서 가져온 데이터가 있으면 업데이트
final fetchedData = state.fetchedRestaurantData;
if (fetchedData != null) {
final coords = await _resolveCoordinates(
latitudeText: state.formData.latitude,
longitudeText: state.formData.longitude,
roadAddress: state.formData.roadAddress,
jibunAddress: state.formData.jibunAddress,
fallbackLatitude: fetchedData.latitude,
fallbackLongitude: fetchedData.longitude,
);
restaurantToSave = fetchedData.copyWith(
name: state.formData.name,
category: state.formData.category,
@@ -264,19 +274,28 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
jibunAddress: state.formData.jibunAddress.isEmpty
? state.formData.roadAddress
: state.formData.jibunAddress,
latitude:
double.tryParse(state.formData.latitude) ?? fetchedData.latitude,
longitude:
double.tryParse(state.formData.longitude) ??
fetchedData.longitude,
latitude: coords.latitude,
longitude: coords.longitude,
naverUrl: state.formData.naverUrl.isEmpty
? null
: state.formData.naverUrl,
updatedAt: DateTime.now(),
needsAddressVerification: coords.usedCurrentLocation,
);
} else {
// 직접 입력한 경우
restaurantToSave = state.formData.toRestaurant();
final coords = await _resolveCoordinates(
latitudeText: state.formData.latitude,
longitudeText: state.formData.longitude,
roadAddress: state.formData.roadAddress,
jibunAddress: state.formData.jibunAddress,
);
restaurantToSave = state.formData.toRestaurant().copyWith(
latitude: coords.latitude,
longitude: coords.longitude,
needsAddressVerification: coords.usedCurrentLocation,
);
}
await notifier.addRestaurantDirect(restaurantToSave);
@@ -297,6 +316,68 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
void clearError() {
state = state.copyWith(clearError: true);
}
Future<({double latitude, double longitude, bool usedCurrentLocation})>
_resolveCoordinates({
required String latitudeText,
required String longitudeText,
required String roadAddress,
required String jibunAddress,
double? fallbackLatitude,
double? fallbackLongitude,
}) async {
final parsedLat = double.tryParse(latitudeText);
final parsedLon = double.tryParse(longitudeText);
if (parsedLat != null && parsedLon != null) {
return (
latitude: parsedLat,
longitude: parsedLon,
usedCurrentLocation: false,
);
}
final geocodingService = _ref.read(geocodingServiceProvider);
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
if (address.isNotEmpty) {
final result = await geocodingService.geocode(address);
if (result != null) {
return (
latitude: result.latitude,
longitude: result.longitude,
usedCurrentLocation: false,
);
}
}
// 주소로 좌표를 얻지 못하면 현재 위치를 활용한다.
try {
final position = await _ref.read(currentLocationProvider.future);
if (position != null) {
return (
latitude: position.latitude,
longitude: position.longitude,
usedCurrentLocation: true,
);
}
} catch (_) {
// 위치 권한 거부/오류 시 fallback 사용
}
if (fallbackLatitude != null && fallbackLongitude != null) {
return (
latitude: fallbackLatitude,
longitude: fallbackLongitude,
usedCurrentLocation: false,
);
}
final defaultCoords = geocodingService.defaultCoordinates();
return (
latitude: defaultCoords.latitude,
longitude: defaultCoords.longitude,
usedCurrentLocation: true,
);
}
}
/// AddRestaurantViewModel Provider

View File

@@ -33,6 +33,7 @@ class CategorySelector extends ConsumerWidget {
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (showAllOption && !multiSelect) ...[
_buildCategoryChip(