feat(app): seed restaurants, geocode addresses, refresh sharing
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -223,7 +223,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user