Files
lunchpick/lib/presentation/pages/share/share_screen.dart

752 lines
24 KiB
Dart

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<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;
List<ShareDevice>? _nearbyDevices;
StreamSubscription<String>? _dataSubscription;
ProviderSubscription<bool>? _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<bool>(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: 220,
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: () {
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 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<void> _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<void> _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<void>.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<void> _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<Restaurant> _parseReceivedData(String data) {
final jsonList = jsonDecode(data) as List<dynamic>;
return jsonList
.map((item) => _restaurantFromJson(item as Map<String, dynamic>))
.toList();
}
Restaurant _restaurantFromJson(Map<String, dynamic> 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<void> _mergeRestaurantList(List<Restaurant> receivedList) async {
final currentList = await ref.read(restaurantListProvider.future);
final notifier = ref.read(restaurantNotifierProvider.notifier);
final newRestaurants = <Restaurant>[];
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;
});
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<void> _handleDebugToggleChange(bool enabled) async {
if (!mounted) return;
setState(() {
_debugPreviewEnabled = enabled;
});
if (enabled) {
await _startDebugPreviewFlow();
} else {
_stopDebugPreviewFlow();
}
}
Future<void> _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<ShareDevice> _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<Restaurant> _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,
),
];
}
}