import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:sighej/screens/profile_screen.dart'; import 'package:sighej/services/api_service.dart'; import 'package:sighej/services/session_store.dart'; // BLE service UUID that identifies SigHej devices. const String kSigHejServiceUuid = '1248f5a0-0000-1000-8000-00805f9b34fb'; // Manufacturer ID used in BLE advertising data (Android). const int kManufacturerId = 0x4E58; // "NX" final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); const _androidChannel = AndroidNotificationDetails( 'sighej_nudge', 'SigHej Nudges', channelDescription: 'Notifies when a nearby person shares your interests', importance: Importance.high, priority: Priority.high, icon: '@mipmap/ic_launcher', ); class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { bool _openToTalk = false; String? _lastNudge; StreamSubscription? _bleSub; // Track tokens we have already matched this session to avoid spam. final Set _matchedTokens = {}; @override void initState() { super.initState(); _initNotifications(); } Future _initNotifications() async { const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const ios = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); await _notifications.initialize( const InitializationSettings(android: android, iOS: ios), ); } @override void dispose() { _stopScanning(); super.dispose(); } Future _toggle(SessionStore store) async { if (_openToTalk) { _stopScanning(); return; } final granted = await _requestPermissions(); if (!granted) return; await registerSession( store.bleToken, store.interests, name: store.name, tagline: store.tagline, ); await _startAdvertising(store.bleToken); FlutterBluePlus.startScan(timeout: const Duration(minutes: 120)); _bleSub = FlutterBluePlus.onScanResults.listen((results) async { for (final r in results) { final detected = _parseSigHejToken(r); if (detected == null || detected == store.bleToken || _matchedTokens.contains(detected)) { continue; } _matchedTokens.add(detected); await _handlePotentialMatch(store.bleToken, detected, store); } }); setState(() => _openToTalk = true); } Future _startAdvertising(String token) async { final tokenBytes = Uint8List.fromList(token.codeUnits); try { await FlutterBluePlus.startAdvertising( AdvertiseData( serviceUuids: [Guid(kSigHejServiceUuid)], manufacturerData: [ManufacturerData(kManufacturerId, tokenBytes)], ), ); } catch (e) { debugPrint('BLE advertise error: $e'); } } void _stopScanning() { FlutterBluePlus.stopScan(); FlutterBluePlus.stopAdvertising(); _bleSub?.cancel(); _bleSub = null; _matchedTokens.clear(); setState(() { _openToTalk = false; _lastNudge = null; }); } Future _handlePotentialMatch( String own, String detected, SessionStore store) async { try { final result = await reportMatch(own, detected); if (result == null || !result.match) return; final interests = result.sharedInterests; final body = interests.isEmpty ? '${store.displayName} — nogen i nærheden er åben for en snak!' : 'Fælles interesser: ${interests.join(', ')}'; await _showNudgeNotification('SigHej — sig hej!', body); setState(() => _lastNudge = body); } catch (e) { debugPrint('Match error: $e'); } } Future _showNudgeNotification(String title, String body) async { const details = NotificationDetails(android: _androidChannel); await _notifications.show( body.hashCode, title, body, details, ); } /// Parses a SigHej BLE token from a scan result. /// Android: reads manufacturer data with key [kManufacturerId]. /// iOS fallback: reads service data for [kSigHejServiceUuid]. String? _parseSigHejToken(ScanResult result) { final ad = result.advertisementData; // Filter: only process SigHej advertisers. final hasSigHejUuid = ad.serviceUuids .map((g) => g.toString().toLowerCase()) .contains(kSigHejServiceUuid.toLowerCase()); if (!hasSigHejUuid) return null; // Android: manufacturer data. final mfr = ad.manufacturerData[kManufacturerId]; if (mfr != null && mfr.isNotEmpty) { try { return String.fromCharCodes(mfr); } catch (_) {} } // iOS fallback: service data. final svcData = ad.serviceData[Guid(kSigHejServiceUuid)]; if (svcData != null && svcData.isNotEmpty) { try { return String.fromCharCodes(svcData); } catch (_) {} } return null; } Future _requestPermissions() async { final perms = [ Permission.bluetooth, Permission.bluetoothScan, Permission.bluetoothAdvertise, Permission.bluetoothConnect, Permission.location, Permission.notification, ]; final statuses = await perms.request(); return statuses.values.every((s) => s.isGranted || s.isLimited); } @override Widget build(BuildContext context) { final store = context.watch(); return Scaffold( appBar: AppBar( title: const Text('SigHej'), actions: [ IconButton( icon: const Icon(Icons.person_outline), onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const ProfileScreen()), ), tooltip: 'Rediger profil', ), ], ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _openToTalk ? 'You\'re open to talk' : 'Tap to open up', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 32), GestureDetector( onTap: () => _toggle(store), child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: 150, height: 150, decoration: BoxDecoration( shape: BoxShape.circle, color: _openToTalk ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, ), child: Icon( _openToTalk ? Icons.record_voice_over : Icons.mic_off, size: 60, color: _openToTalk ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), if (_lastNudge != null) ...[ const SizedBox(height: 40), Card( margin: const EdgeInsets.symmetric(horizontal: 24), child: Padding( padding: const EdgeInsets.all(16), child: Text( _lastNudge!, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), ), ), ], ], ), ), ); } }