import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; 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/core/services/permission_service.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; 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/debug_share_preview_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; import 'package:lunchpick/presentation/providers/settings_provider.dart'; import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart'; import 'package:uuid/uuid.dart'; class ShareScreen extends ConsumerStatefulWidget { const ShareScreen({super.key}); @override ConsumerState 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 { String? _shareCode; bool _isScanning = false; List? _nearbyDevices; StreamSubscription? _dataSubscription; ProviderSubscription? _debugPreviewSub; final _uuid = const Uuid(); bool _debugPreviewEnabled = false; Timer? _debugPreviewTimer; @override void initState() { super.initState(); final bluetoothService = ref.read(bluetoothServiceProvider); _dataSubscription = bluetoothService.onDataReceived.listen((payload) { _handleIncomingData(payload); }); _debugPreviewEnabled = ref.read(debugSharePreviewProvider); if (_debugPreviewEnabled) { WidgetsBinding.instance.addPostFrameCallback((_) { _handleDebugToggleChange(true); }); } _debugPreviewSub = ref.listenManual(debugSharePreviewProvider, ( previous, next, ) { if (previous == next) return; _handleDebugToggleChange(next); }); } @override void dispose() { _dataSubscription?.cancel(); _debugPreviewSub?.close(); ref.read(bluetoothServiceProvider).stopListening(); _debugPreviewTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final screenshotModeEnabled = ref .watch(screenshotModeEnabledProvider) .maybeWhen(data: (value) => value, orElse: () => false); return Scaffold( backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground, appBar: AppBar( title: const Text('리스트 공유'), backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary, foregroundColor: Colors.white, elevation: 0, ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), 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), NativeAdPlaceholder( height: 360, enabled: !screenshotModeEnabled, ), const SizedBox(height: 16), _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: () async { setState(() { _shareCode = null; }); await 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 _generateShareCode() async { final adWatched = await ref .read(adServiceProvider) .showInterstitialAd(context); if (!mounted) return; if (!adWatched) { _showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.'); return; } if (kDebugMode && _debugPreviewEnabled) { setState(() { _shareCode = _shareCode ?? _buildDebugShareCode(); }); _showSuccessSnackBar('디버그 공유 코드가 준비되었습니다.'); return; } final hasPermission = await PermissionService.checkAndRequestBluetoothPermission(); if (!hasPermission) { if (!mounted) return; _showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.'); return; } final random = Random(); final code = List.generate(6, (_) => random.nextInt(10)).join(); try { await ref.read(bluetoothServiceProvider).startListening(code); if (!mounted) return; setState(() { _shareCode = code; }); _showSuccessSnackBar('공유 코드가 생성되었습니다.'); } catch (_) { if (!mounted) return; _showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'); } } Future _scanDevices() async { if (kDebugMode && _debugPreviewEnabled) { setState(() { _isScanning = true; _nearbyDevices = _buildDebugDevices(); }); _scheduleDebugReceive(); return; } final hasPermission = await PermissionService.checkAndRequestBluetoothPermission(); if (!hasPermission) { if (!mounted) return; _showErrorSnackBar('블루투스 권한이 필요합니다.'); return; } setState(() { _isScanning = true; _nearbyDevices = []; }); try { final devices = await ref .read(bluetoothServiceProvider) .scanNearbyDevices(); if (!mounted) return; setState(() { _nearbyDevices = devices; }); } catch (e) { if (!mounted) return; setState(() { _isScanning = false; }); _showErrorSnackBar('스캔 중 오류가 발생했습니다.'); } } Future _sendList(String targetCode) async { final adWatched = await ref .read(adServiceProvider) .showInterstitialAd(context); if (!mounted) return; if (!adWatched) { _showErrorSnackBar('광고를 끝까지 시청해야 리스트를 전송할 수 있어요.'); return; } if (kDebugMode && _debugPreviewEnabled) { _showLoadingDialog('리스트 전송 중...'); await Future.delayed(const Duration(milliseconds: 700)); if (!mounted) return; Navigator.pop(context); _showSuccessSnackBar('디버그 전송 완료! (실제 전송 없음)'); setState(() { _isScanning = false; _nearbyDevices = null; }); return; } final restaurants = await ref.read(restaurantListProvider.future); if (!mounted) return; _showLoadingDialog('리스트 전송 중...'); try { await ref .read(bluetoothServiceProvider) .sendRestaurantList(targetCode, restaurants); if (!mounted) return; Navigator.pop(context); _showSuccessSnackBar('리스트 전송 완료!'); setState(() { _isScanning = false; _nearbyDevices = null; }); } catch (e) { if (!mounted) return; Navigator.pop(context); _showErrorSnackBar('전송 실패: $e'); } } Future _handleIncomingData( String payload, { bool skipAd = false, }) async { if (!mounted) return; if (!skipAd) { final adWatched = await ref .read(adServiceProvider) .showInterstitialAd(context); if (!mounted) return; if (!adWatched) { _showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.'); return; } } try { final restaurants = _parseReceivedData(payload); await _mergeRestaurantList(restaurants); } catch (_) { _showErrorSnackBar('전송된 데이터를 처리하는 데 실패했습니다.'); } } List _parseReceivedData(String data) { final jsonList = jsonDecode(data) as List; return jsonList .map((item) => _restaurantFromJson(item as Map)) .toList(); } Restaurant _restaurantFromJson(Map json) { return Restaurant( id: json['id'] as String, name: json['name'] as String, category: json['category'] as String, subCategory: json['subCategory'] as String, description: json['description'] as String?, phoneNumber: json['phoneNumber'] as String?, roadAddress: json['roadAddress'] as String, jibunAddress: json['jibunAddress'] as String, latitude: (json['latitude'] as num).toDouble(), longitude: (json['longitude'] as num).toDouble(), lastVisitDate: json['lastVisitDate'] != null ? DateTime.parse(json['lastVisitDate'] as String) : null, source: DataSource.values.firstWhere( (source) => source.name == (json['source'] as String? ?? DataSource.USER_INPUT.name), orElse: () => DataSource.USER_INPUT, ), createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), naverPlaceId: json['naverPlaceId'] as String?, naverUrl: json['naverUrl'] as String?, businessHours: json['businessHours'] as String?, lastVisited: json['lastVisited'] != null ? DateTime.parse(json['lastVisited'] as String) : null, visitCount: (json['visitCount'] as num?)?.toInt() ?? 0, ); } Future _mergeRestaurantList(List receivedList) async { final currentList = await ref.read(restaurantListProvider.future); final notifier = ref.read(restaurantNotifierProvider.notifier); final newRestaurants = []; for (final restaurant in receivedList) { final exists = currentList.any( (existing) => existing.name == restaurant.name && existing.roadAddress == restaurant.roadAddress, ); if (!exists) { newRestaurants.add( restaurant.copyWith( id: _uuid.v4(), createdAt: DateTime.now(), updatedAt: DateTime.now(), source: DataSource.USER_INPUT, ), ); } } for (final restaurant in newRestaurants) { await notifier.addRestaurantDirect(restaurant); } if (!mounted) return; if (newRestaurants.isEmpty) { _showSuccessSnackBar('이미 등록된 맛집과 동일한 항목만 전송되었습니다.'); } else { _showSuccessSnackBar('${newRestaurants.length}개의 새로운 맛집이 추가되었습니다!'); } setState(() { _shareCode = null; }); await ref.read(bluetoothServiceProvider).stopListening(); } void _showLoadingDialog(String message) { final isDark = Theme.of(context).brightness == Brightness.dark; showDialog( context: context, barrierDismissible: false, builder: (context) => Dialog( backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, child: Padding( padding: const EdgeInsets.all(20), child: Row( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: AppColors.lightPrimary), const SizedBox(width: 20), Flexible( child: Text(message, style: AppTypography.body2(isDark)), ), ], ), ), ), ); } void _showSuccessSnackBar(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: AppColors.lightPrimary), ); } void _showErrorSnackBar(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: AppColors.lightError), ); } Future _handleDebugToggleChange(bool enabled) async { if (!mounted) return; setState(() { _debugPreviewEnabled = enabled; }); if (enabled) { await _startDebugPreviewFlow(); } else { _stopDebugPreviewFlow(); } } Future _startDebugPreviewFlow() async { _debugPreviewTimer?.cancel(); final code = _buildDebugShareCode(); setState(() { _shareCode = code; _isScanning = true; _nearbyDevices = _buildDebugDevices(); }); _scheduleDebugReceive(); } void _stopDebugPreviewFlow() { _debugPreviewTimer?.cancel(); setState(() { _shareCode = null; _isScanning = false; _nearbyDevices = null; }); } void _scheduleDebugReceive() { _debugPreviewTimer?.cancel(); _debugPreviewTimer = Timer(const Duration(seconds: 1), () { if (!mounted || !_debugPreviewEnabled) return; final payload = _buildDebugPayload(); _handleIncomingData(payload, skipAd: true); }); } String _buildDebugShareCode() => 'DBG${Random().nextInt(900000) + 100000}'; List _buildDebugDevices() { final now = DateTime.now(); return [ ShareDevice(code: 'DBG-ALPHA', deviceId: 'LP-DEBUG-1', discoveredAt: now), ShareDevice( code: 'DBG-BETA', deviceId: 'LP-DEBUG-2', discoveredAt: now.subtract(const Duration(seconds: 10)), ), ]; } String _buildDebugPayload() { final samples = _buildDebugRestaurants(); final list = samples .map( (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?.toIso8601String(), 'source': restaurant.source.name, 'createdAt': restaurant.createdAt.toIso8601String(), 'updatedAt': restaurant.updatedAt.toIso8601String(), 'naverPlaceId': restaurant.naverPlaceId, 'naverUrl': restaurant.naverUrl, 'businessHours': restaurant.businessHours, 'lastVisited': restaurant.lastVisited?.toIso8601String(), 'visitCount': restaurant.visitCount, }, ) .toList(); return jsonEncode(list); } List _buildDebugRestaurants() { final now = DateTime.now(); return [ Restaurant( id: 'debug-share-ramen', name: '디버그 라멘바', category: 'Japanese', subCategory: 'Ramen', description: '테스트용 라멘 바. 실제 전송 없이 미리보기 용도입니다.', phoneNumber: '02-111-1111', roadAddress: '서울 특별시 테스트로 1', jibunAddress: '서울 테스트동 1-1', latitude: 37.566, longitude: 126.9784, lastVisitDate: now.subtract(const Duration(days: 2)), source: DataSource.PRESET, createdAt: now, updatedAt: now, naverPlaceId: null, naverUrl: null, businessHours: '11:00 - 21:00', lastVisited: now.subtract(const Duration(days: 2)), visitCount: 3, ), Restaurant( id: 'debug-share-burger', name: '샘플 버거샵', category: 'Fastfood', subCategory: 'Burger', description: '광고·권한 없이 교환 흐름을 확인하는 샘플 버거 가게.', phoneNumber: '02-222-2222', roadAddress: '서울 특별시 디버그길 22', jibunAddress: '서울 디버그동 22-2', latitude: 37.57, longitude: 126.982, lastVisitDate: now.subtract(const Duration(days: 5)), source: DataSource.PRESET, createdAt: now, updatedAt: now, naverPlaceId: null, naverUrl: null, businessHours: '10:00 - 23:00', lastVisited: now.subtract(const Duration(days: 5)), visitCount: 1, ), Restaurant( id: 'debug-share-brunch', name: '프리뷰 브런치 카페', category: 'Cafe', subCategory: 'Brunch', description: '리스트 공유 수신 UI를 확인하기 위한 브런치 카페 샘플.', phoneNumber: '02-333-3333', roadAddress: '서울 특별시 미리보기로 33', jibunAddress: '서울 미리보기동 33-3', latitude: 37.561, longitude: 126.99, lastVisitDate: now.subtract(const Duration(days: 1)), source: DataSource.PRESET, createdAt: now, updatedAt: now, naverPlaceId: null, naverUrl: null, businessHours: '09:00 - 18:00', lastVisited: now.subtract(const Duration(days: 1)), visitCount: 4, ), ]; } }