import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/api_provider.dart'; import '../../models/post.dart'; import '../../models/beacon.dart'; import '../../models/cluster.dart'; import '../../models/board_entry.dart'; import '../../models/local_intel.dart'; import '../../services/api_service.dart'; import '../../services/auth_service.dart'; import '../../services/local_intel_service.dart'; import '../../services/capsule_security_service.dart'; import '../../widgets/safety/active_alerts_ticker.dart'; import 'beacon_detail_screen.dart'; import 'create_beacon_sheet.dart'; import 'create_board_post_sheet.dart'; import 'board_entry_detail_screen.dart'; import '../clusters/group_screen.dart'; import '../clusters/group_chat_tab.dart'; import '../clusters/group_forum_tab.dart'; import '../clusters/group_members_tab.dart'; import '../../theme/tokens.dart'; import '../../theme/app_theme.dart'; import '../../widgets/neighborhood/neighborhood_picker_sheet.dart'; enum BeaconTab { map, board, search, groups } enum NeighborhoodHubTab { feed, chat, forum, members } class BeaconScreen extends ConsumerStatefulWidget { static final GlobalKey globalKey = GlobalKey(); final LatLng? initialMapCenter; BeaconScreen({this.initialMapCenter}) : super(key: globalKey); @override ConsumerState createState() => BeaconScreenState(); } class BeaconScreenState extends ConsumerState with TickerProviderStateMixin { static const List _tabOrder = [ BeaconTab.map, BeaconTab.board, BeaconTab.groups, BeaconTab.search, ]; final MapController _mapController = MapController(); final DraggableScrollableController _sheetController = DraggableScrollableController(); final LocalIntelService _intelService = LocalIntelService(); late final TabController _tabController; List _beacons = []; List _beaconModels = []; bool _isLoading = false; bool _isLoadingLocation = false; late LatLng _mapCenter; LatLng? _userLocation; String _locationName = 'Locating…'; bool _locationPermissionGranted = false; double _currentZoom = 14.0; bool _suppressAutoCenterOnUser = false; WeatherConditions? _weather; // Sub-menu tab state BeaconTab _activeTab = BeaconTab.map; // Board entries (standalone — NOT posts) List _boardEntries = []; bool _isLoadingBoard = false; bool _isNeighborhoodAdmin = false; BoardTopic? _selectedBoardTopic; String _boardSort = 'new'; NeighborhoodHubTab _activeNeighborhoodHubTab = NeighborhoodHubTab.feed; int _activeNeighborhoodMemberCount = 0; int _chatActivityCount = 0; int _forumActivityCount = 0; int _activeNowCount = 0; bool _isLoadingNeighborhoodHubMeta = false; String? _lastNeighborhoodMetaGroupId; // Groups / clusters data List _clusters = []; bool _isLoadingClusters = false; Map _encryptedKeys = {}; GroupCategory? _selectedGroupCategory; // Neighborhood detection state Map? _neighborhood; bool _isDetectingNeighborhood = false; bool _neighborhoodDetected = false; bool _homeNeighborhoodChecked = false; // Beacon search state final _searchController = TextEditingController(); bool _isSearching = false; List> _searchBeacons = []; List> _searchBoard = []; List> _searchGroups = []; Timer? _searchDebounce; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(() { if (!_tabController.indexIsChanging) { setState(() => _activeTab = _tabOrder[_tabController.index]); if (_tabController.index == 1 && _boardEntries.isEmpty) _loadBoardEntries(); } }); _mapCenter = widget.initialMapCenter ?? const LatLng(37.7749, -122.4194); _suppressAutoCenterOnUser = widget.initialMapCenter != null; if (widget.initialMapCenter != null) { _loadBeacons(center: widget.initialMapCenter); } _checkLocationPermission(); _loadClusters(); _checkHomeNeighborhood(); } @override void dispose() { _tabController.dispose(); _searchController.dispose(); _searchDebounce?.cancel(); super.dispose(); } Future _checkLocationPermission() async { final status = await Permission.location.status; if (mounted) { setState(() => _locationPermissionGranted = status.isGranted); if (status.isGranted) { await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser); await _loadBeacons(); } } } Future _requestLocationPermission() async { setState(() => _isLoadingLocation = true); try { final status = await Permission.location.request(); setState(() => _locationPermissionGranted = status.isGranted); if (status.isGranted) { await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser); await _loadBeacons(); } else { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Location access is required to show nearby incidents.')), ); } } finally { if (mounted) setState(() => _isLoadingLocation = false); } } Future _getCurrentLocation({bool forceCenter = false}) async { if (!_locationPermissionGranted) return; setState(() => _isLoadingLocation = true); try { final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); if (mounted) { setState(() { _userLocation = LatLng(position.latitude, position.longitude); if (forceCenter || !_suppressAutoCenterOnUser) { _mapController.move(_userLocation!, _currentZoom); _suppressAutoCenterOnUser = false; } }); // Fetch weather for current location _fetchWeather(position.latitude, position.longitude); // Detect neighborhood if (!_neighborhoodDetected) _detectNeighborhood(position.latitude, position.longitude); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not get location: $e'))); } } finally { if (mounted) setState(() => _isLoadingLocation = false); } } Future _fetchWeather(double lat, double lng) async { try { final weather = await _intelService.fetchWeather(lat, lng); if (mounted) setState(() => _weather = weather); } catch (_) {} } Future _detectNeighborhood(double lat, double lng) async { if (_isDetectingNeighborhood) return; setState(() => _isDetectingNeighborhood = true); try { final data = await ApiService.instance.detectNeighborhood(lat: lat, long: lng); if (mounted) { setState(() { _neighborhood = data; _neighborhoodDetected = true; _isDetectingNeighborhood = false; }); // Update location name with neighborhood final hood = data['neighborhood'] as Map?; if (hood != null) { final name = hood['name'] as String? ?? ''; final city = hood['city'] as String? ?? ''; if (name.isNotEmpty) { setState(() => _locationName = city.isNotEmpty ? '$name, $city' : name); } } // If user has no home neighborhood yet, show the picker if (!_homeNeighborhoodChecked) { _homeNeighborhoodChecked = true; _maybeShowNeighborhoodPicker(); } } } catch (e) { if (kDebugMode) print('[Neighborhood] Detect error: $e'); if (mounted) setState(() => _isDetectingNeighborhood = false); } } /// Check if the user already completed the neighborhood onboarding. Future _checkHomeNeighborhood() async { try { final mine = await ApiService.instance.getMyNeighborhood(); if (mine != null && mounted) { final onboarded = mine['onboarded'] == true; final hasHood = mine['neighborhood'] != null; setState(() { _homeNeighborhoodChecked = true; if (hasHood) { _neighborhood = mine; _neighborhoodDetected = true; } }); // If user hasn't completed onboarding, the picker will be shown // once GPS detection finishes (or immediately if GPS was already done) if (!onboarded && _neighborhoodDetected) { Future.delayed(const Duration(milliseconds: 600), () { if (mounted) _showNeighborhoodPicker(); }); } } } catch (_) { // Network error — don't block the screen } } /// Show the neighborhood picker if the user hasn't completed onboarding. void _maybeShowNeighborhoodPicker() { // Check the onboarded flag from the /mine response if (_neighborhood != null && _neighborhood!['onboarded'] == true) return; // Delay slightly so the screen is fully visible first Future.delayed(const Duration(milliseconds: 600), () { if (!mounted) return; _showNeighborhoodPicker(); }); } Future _showNeighborhoodPicker({bool isChangeMode = false, String? nextChangeDate}) async { final result = await NeighborhoodPickerSheet.show( context, isChangeMode: isChangeMode, nextChangeDate: nextChangeDate, ); if (result != null && mounted) { setState(() { _neighborhood = result; _neighborhoodDetected = true; }); // Update location name final hood = result['neighborhood'] as Map?; if (hood != null) { final name = hood['name'] as String? ?? ''; final city = hood['city'] as String? ?? ''; if (name.isNotEmpty) { setState(() => _locationName = city.isNotEmpty ? '$name, $city' : name); } } } } Future _loadBeacons({LatLng? center}) async { final target = center ?? _userLocation ?? _mapCenter; setState(() => _isLoading = true); try { final apiService = ref.read(apiServiceProvider); final beacons = await apiService.fetchNearbyBeacons( lat: target.latitude, long: target.longitude, radius: 16000, ); if (mounted) { setState(() { _beacons = beacons.where((p) => p.isBeaconPost).toList(); _beaconModels = _beacons.map((p) => p.toBeacon()).toList(); _isLoading = false; }); } } catch (e) { if (mounted) setState(() => _isLoading = false); } } Future _loadClusters() async { setState(() => _isLoadingClusters = true); try { final groups = await ApiService.instance.fetchMyGroups(); final allClusters = groups.map((g) => Cluster.fromJson(g)).toList(); if (mounted) { setState(() { _clusters = allClusters; _encryptedKeys = { for (final g in groups) if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true) g['id'] as String: g['encrypted_group_key'] as String, }; _isLoadingClusters = false; }); } } catch (e) { if (kDebugMode) print('[Beacon] Clusters load error: $e'); if (mounted) setState(() => _isLoadingClusters = false); } } void _navigateToCluster(Cluster cluster) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => GroupScreen( group: cluster, encryptedGroupKey: _encryptedKeys[cluster.id], ), )); } void _onMapPositionChanged(MapCamera camera, bool hasGesture) { _mapCenter = camera.center; _currentZoom = camera.zoom; if (hasGesture) _loadBeacons(center: _mapCenter); } void _onMarkerTap(Post post) { Navigator.of(context).push(MaterialPageRoute( builder: (context) => BeaconDetailScreen(beaconPost: post), )); } void _onBeaconModelTap(Beacon beacon) { // Find matching post final post = _beacons.where((p) => p.id == beacon.id).firstOrNull; if (post != null) _onMarkerTap(post); } void _onCreateBeacon({BeaconType? preselectedType}) { showModalBottomSheet( context: context, backgroundColor: SojornColors.transparent, isScrollControlled: true, builder: (context) => CreateBeaconSheet( centerLat: _mapCenter.latitude, centerLong: _mapCenter.longitude, onBeaconCreated: (post) { setState(() => _beacons.add(post)); _loadBeacons(); }, ), ); } Widget _buildBoardSortChip(String key, String label) { final selected = _boardSort == key; return Material( color: Colors.transparent, child: InkWell( onTap: () { if (selected) return; setState(() => _boardSort = key); _loadBoardEntries(); }, borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: selected ? AppTheme.brightNavy : AppTheme.cardSurface, borderRadius: BorderRadius.circular(8), border: Border.all( color: selected ? AppTheme.brightNavy : AppTheme.navyBlue.withValues(alpha: 0.12), width: 1, ), ), child: Text( label, style: TextStyle( color: selected ? SojornColors.basicWhite : AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ), ); } Widget _buildHeroStat(IconData icon, String value, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: AppTheme.brightNavy), const SizedBox(width: 4), Text( value, style: TextStyle(color: AppTheme.navyBlue, fontSize: 12, fontWeight: FontWeight.w700), ), const SizedBox(width: 4), Text( label, style: TextStyle(color: SojornColors.textDisabled, fontSize: 11, fontWeight: FontWeight.w500), ), ], ), ); } Future _loadBoardEntries() async { if (_isLoadingBoard) return; setState(() => _isLoadingBoard = true); try { final data = await ApiService.instance.fetchBoardEntries( lat: _mapCenter.latitude, long: _mapCenter.longitude, topic: _selectedBoardTopic?.value, sort: _boardSort, ); if (mounted) { setState(() { final rawEntries = (data['entries'] as List?)?.cast>() ?? []; _boardEntries = rawEntries.map((e) => BoardEntry.fromJson(e)).toList(); _isNeighborhoodAdmin = data['is_neighborhood_admin'] == true; _isLoadingBoard = false; }); } } catch (e) { if (kDebugMode) print('[Board] Load error: $e'); if (mounted) setState(() => _isLoadingBoard = false); } final groupId = _neighborhoodGroupId; if (groupId != null && groupId.isNotEmpty) { _loadNeighborhoodHubMeta(groupId); } } String? get _neighborhoodGroupId { if (_neighborhood == null) return null; final direct = _neighborhood!['group_id'] as String?; if (direct != null && direct.isNotEmpty) return direct; final nested = _neighborhood!['neighborhood'] as Map?; return nested?['group_id'] as String?; } String get _neighborhoodName { if (_neighborhood == null) return 'Neighborhood'; final direct = _neighborhood!['name'] as String?; if (direct != null && direct.isNotEmpty) return direct; final nested = _neighborhood!['neighborhood'] as Map?; final nestedName = nested?['name'] as String?; return (nestedName != null && nestedName.isNotEmpty) ? nestedName : 'Neighborhood'; } Future _loadNeighborhoodHubMeta(String groupId) async { if (_isLoadingNeighborhoodHubMeta) return; setState(() => _isLoadingNeighborhoodHubMeta = true); try { final members = await ApiService.instance.fetchGroupMembers(groupId); final messages = await ApiService.instance.fetchGroupMessages(groupId, limit: 40); final threads = await ApiService.instance.fetchGroupThreads(groupId, limit: 40); final nowUtc = DateTime.now().toUtc(); final activeCutoff = nowUtc.subtract(const Duration(minutes: 30)); final chatCutoff = nowUtc.subtract(const Duration(hours: 12)); final forumCutoff = nowUtc.subtract(const Duration(hours: 18)); final activeUserIds = {}; int chatActivity = 0; for (final m in messages) { final createdAtRaw = m['created_at']?.toString(); final authorId = m['author_id']?.toString(); if (createdAtRaw == null || authorId == null) continue; final createdAt = DateTime.tryParse(createdAtRaw)?.toUtc(); if (createdAt == null) continue; if (createdAt.isAfter(chatCutoff)) chatActivity++; if (createdAt.isAfter(activeCutoff)) activeUserIds.add(authorId); } int forumActivity = 0; for (final t in threads) { final stampRaw = t['last_activity_at']?.toString() ?? t['created_at']?.toString(); final authorId = t['author_id']?.toString(); if (stampRaw == null || authorId == null) continue; final stamp = DateTime.tryParse(stampRaw)?.toUtc(); if (stamp == null) continue; if (stamp.isAfter(forumCutoff)) forumActivity++; if (stamp.isAfter(activeCutoff)) activeUserIds.add(authorId); } if (mounted) { setState(() { _activeNeighborhoodMemberCount = members.length; _chatActivityCount = chatActivity; _forumActivityCount = forumActivity; _activeNowCount = activeUserIds.length; _isLoadingNeighborhoodHubMeta = false; }); } } catch (_) { if (mounted) { setState(() => _isLoadingNeighborhoodHubMeta = false); } } } Future _removeBoardEntry(BoardEntry entry) async { final reasonController = TextEditingController(); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Remove Content'), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Are you sure you want to remove this post? This action will be logged.'), const SizedBox(height: 10), Material( color: Colors.transparent, child: TextField( controller: reasonController, decoration: const InputDecoration(labelText: 'Reason for removal', hintText: 'e.g. Hate speech, spam...'), ), ), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Remove', style: TextStyle(color: SojornColors.destructive)), ), ], ), ); if (confirm == true && reasonController.text.isNotEmpty) { try { await ApiService.instance.removeBoardEntry(entry.id, reasonController.text); if (mounted) { setState(() { _boardEntries.removeWhere((e) => e.id == entry.id); }); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Content removed'))); } } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to remove: $e'))); } } } Future _flagBoardEntry(BoardEntry entry) async { final reasonController = TextEditingController(); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Report Content'), content: Material( color: Colors.transparent, child: TextField( controller: reasonController, decoration: const InputDecoration(labelText: 'Reason', hintText: 'Why is this inappropriate?'), ), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Report')), ], ), ); if (confirm == true && reasonController.text.isNotEmpty) { try { await ApiService.instance.flagBoardEntry(entry.id, reasonController.text); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Report submitted. Thank you.'))); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to report: $e'))); } } } void _onCreateBoardPost() { showModalBottomSheet( context: context, backgroundColor: SojornColors.transparent, isScrollControlled: true, builder: (context) => CreateBoardPostSheet( centerLat: _mapCenter.latitude, centerLong: _mapCenter.longitude, onEntryCreated: (entry) { setState(() => _boardEntries.insert(0, entry)); }, ), ); } void _onSearchChanged(String query) { _searchDebounce?.cancel(); if (query.trim().isEmpty) { setState(() { _searchBeacons = []; _searchBoard = []; _searchGroups = []; _isSearching = false; }); return; } _searchDebounce = Timer(const Duration(milliseconds: 400), () => _performBeaconSearch(query.trim())); } Future _performBeaconSearch(String query) async { setState(() => _isSearching = true); try { final data = await ApiService.instance.beaconSearch( query: query, lat: _userLocation?.latitude ?? _mapCenter.latitude, lng: _userLocation?.longitude ?? _mapCenter.longitude, radius: 50000, ); if (mounted) { setState(() { _searchBeacons = (data['beacons'] as List?)?.cast>() ?? []; _searchBoard = (data['board_entries'] as List?)?.cast>() ?? []; _searchGroups = (data['groups'] as List?)?.cast>() ?? []; _isSearching = false; }); } } catch (e) { if (kDebugMode) print('[BeaconSearch] Error: $e'); if (mounted) setState(() => _isSearching = false); } } Future _toggleBoardVote(BoardEntry entry) async { try { final result = await ApiService.instance.toggleBoardVote(entryId: entry.id); final voted = result['voted'] as bool? ?? false; if (mounted) { setState(() { final idx = _boardEntries.indexWhere((e) => e.id == entry.id); if (idx >= 0) { final old = _boardEntries[idx]; _boardEntries[idx] = BoardEntry( id: old.id, body: old.body, imageUrl: old.imageUrl, topic: old.topic, lat: old.lat, long: old.long, upvotes: voted ? old.upvotes + 1 : old.upvotes - 1, replyCount: old.replyCount, isPinned: old.isPinned, createdAt: old.createdAt, authorHandle: old.authorHandle, authorDisplayName: old.authorDisplayName, authorAvatarUrl: old.authorAvatarUrl, hasVoted: voted, ); } }); } } catch (e) { if (kDebugMode) print('[Board] Vote error: $e'); } } @override Widget build(BuildContext context) { if (!_locationPermissionGranted) { return _buildLocationPermissionOverlay(context); } return Column( children: [ // Top tab bar Material( color: AppTheme.scaffoldBg, child: TabBar( controller: _tabController, labelColor: AppTheme.brightNavy, unselectedLabelColor: SojornColors.textDisabled, indicatorColor: AppTheme.brightNavy, indicatorWeight: 2.5, labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), unselectedLabelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), tabs: const [ Tab(text: 'Map', icon: Icon(Icons.map_outlined, size: SojornNav.beaconTabIconSize), iconMargin: EdgeInsets.only(bottom: 2)), Tab(text: 'Board', icon: Icon(Icons.forum_outlined, size: SojornNav.beaconTabIconSize), iconMargin: EdgeInsets.only(bottom: 2)), Tab(text: 'Groups', icon: Icon(Icons.groups_outlined, size: SojornNav.beaconTabIconSize), iconMargin: EdgeInsets.only(bottom: 2)), Tab(text: 'Search', icon: Icon(Icons.search, size: SojornNav.beaconTabIconSize), iconMargin: EdgeInsets.only(bottom: 2)), ], ), ), // Content Expanded( child: IndexedStack( index: _tabController.index, children: [ _buildMapTab(), _buildBoardView(), _buildGroupsView(), _buildSearchView(), ], ), ), ], ); } // ─── Map tab (map + overlay + draggable sheet) ──────────────────────── Widget _buildMapTab() { return Stack( children: [ _buildMap(), _buildMapOverlayBar(), DraggableScrollableSheet( controller: _sheetController, initialChildSize: 0.15, minChildSize: 0.15, maxChildSize: 0.85, snap: true, snapSizes: const [0.15, 0.5, 0.85], builder: (context, scrollController) { return Container( decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), boxShadow: [ BoxShadow( color: SojornColors.basicBlack.withValues(alpha: 0.12), blurRadius: 20, offset: const Offset(0, -4), ), ], ), child: CustomScrollView( controller: scrollController, slivers: [ SliverToBoxAdapter(child: _buildDragHandle()), SliverToBoxAdapter( child: ActiveAlertsTicker(alerts: _beaconModels, onAlertTap: _onBeaconModelTap), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded(child: Divider(color: AppTheme.navyBlue.withValues(alpha: 0.1))), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('ALL INCIDENTS', style: TextStyle( color: AppTheme.navyBlue.withValues(alpha: 0.35), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1, )), ), Expanded(child: Divider(color: AppTheme.navyBlue.withValues(alpha: 0.1))), ], ), ), ), if (_beacons.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(32), child: Center(child: Column(children: [ Icon(Icons.shield, color: AppTheme.brightNavy.withValues(alpha: 0.3), size: 48), const SizedBox(height: 12), Text('All clear in your area', style: TextStyle(color: AppTheme.navyBlue.withValues(alpha: 0.5), fontSize: 14)), ])), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildIncidentCard(_beacons[index]), childCount: _beacons.length, ), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), ); }, ), ], ); } // ─── Floating map overlay bar (weather + action buttons) ───────────── Widget _buildMapOverlayBar() { return Positioned( top: 8, left: 8, right: 8, child: Row( children: [ // Weather chip (far left) if (_weather != null) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: SojornColors.basicWhite.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: SojornColors.basicBlack.withValues(alpha: 0.1), blurRadius: 6)], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_weatherIcon(_weather!.weatherCode), size: 16, color: AppTheme.navyBlue), const SizedBox(width: 4), Text('${_weather!.temperature.round()}°', style: TextStyle(color: AppTheme.navyBlue, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), const Spacer(), // My location button _mapIconButton(Icons.my_location, onTap: _isLoadingLocation ? null : () => _getCurrentLocation(forceCenter: true)), const SizedBox(width: 8), // Refresh button _mapIconButton(Icons.refresh, onTap: () => _loadBeacons()), ], ), ); } Widget _mapIconButton(IconData icon, {VoidCallback? onTap}) { return GestureDetector( onTap: onTap, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: SojornColors.basicWhite.withValues(alpha: 0.85), shape: BoxShape.circle, boxShadow: [BoxShadow(color: SojornColors.basicBlack.withValues(alpha: 0.1), blurRadius: 6)], ), child: Icon(icon, size: 18, color: AppTheme.navyBlue.withValues(alpha: 0.7)), ), ); } IconData _weatherIcon(int code) { if (code <= 1) return Icons.wb_sunny; if (code <= 3) return Icons.cloud; if (code <= 49) return Icons.foggy; if (code <= 69) return Icons.water_drop; if (code <= 79) return Icons.ac_unit; if (code <= 99) return Icons.thunderstorm; return Icons.cloud; } BeaconTab get activeTab => _activeTab; String get createLabel { switch (_activeTab) { case BeaconTab.map: return 'Report'; case BeaconTab.board: return 'Post'; case BeaconTab.search: return 'Create'; case BeaconTab.groups: return 'New'; } } /// Called from HomeShell app bar when on Beacon tab void onCreateAction() { switch (_activeTab) { case BeaconTab.map: _onCreateBeacon(); break; case BeaconTab.board: _onCreateBoardPost(); break; case BeaconTab.search: _onCreateBeacon(); break; case BeaconTab.groups: _showCreateGroupSheet(); break; } } // ─── Board view (standalone neighborhood board) ───────────────────── Widget _buildBoardView() { if (_neighborhood == null) { return Center( child: _isDetectingNeighborhood ? const CircularProgressIndicator() : Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.location_off, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.2)), const SizedBox(height: 16), const Text('No neighborhood detected', style: TextStyle(color: SojornColors.textDisabled)), TextButton(onPressed: () => _detectNeighborhood(_userLocation?.latitude ?? 0, _userLocation?.longitude ?? 0), child: const Text('Retry')), ], ), ); } final groupId = _neighborhoodGroupId; if (groupId == null || groupId.isEmpty) { return Center( child: Text('Neighborhood unavailable', style: TextStyle(color: SojornColors.textDisabled)), ); } final groupName = _neighborhoodName; final isAdmin = _isNeighborhoodAdmin; if (_lastNeighborhoodMetaGroupId != groupId) { _lastNeighborhoodMetaGroupId = groupId; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _loadNeighborhoodHubMeta(groupId); }); } final matchedCluster = _clusters.where((c) => c.id == groupId).firstOrNull; final hubCluster = matchedCluster ?? Cluster( id: groupId, name: groupName, type: 'geo', memberCount: _activeNeighborhoodMemberCount, createdAt: DateTime.now(), ); final availableSwitchTargets = _clusters.where((c) => c.id != groupId).take(8).toList(); return Column( children: [ Container( color: AppTheme.cardSurface, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 6), child: Row( children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: AppTheme.brightNavy.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon(Icons.home_filled, size: 18, color: AppTheme.brightNavy), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( groupName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w800), ), ), const SizedBox(width: 4), if (availableSwitchTargets.isNotEmpty) PopupMenuButton( tooltip: 'Quick switch', icon: Icon(Icons.keyboard_arrow_down, size: 18, color: SojornColors.textDisabled), onSelected: (value) { final target = _clusters.where((c) => c.id == value).firstOrNull; if (target != null) { _navigateToCluster(target); } }, itemBuilder: (_) => availableSwitchTargets .map( (c) => PopupMenuItem( value: c.id, child: Row( children: [ Icon( c.isCapsule ? Icons.lock : Icons.location_on, size: 14, color: c.isCapsule ? const Color(0xFF4CAF50) : AppTheme.brightNavy, ), const SizedBox(width: 8), Expanded( child: Text( c.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ) .toList(), ), ], ), const SizedBox(height: 3), Row( children: [ Icon(Icons.bolt, size: 12, color: AppTheme.brightNavy.withValues(alpha: 0.8)), const SizedBox(width: 4), Text( _isLoadingNeighborhoodHubMeta ? 'Checking activity...' : '$_activeNowCount active now', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11, fontWeight: FontWeight.w600), ), const SizedBox(width: 10), Icon(Icons.people, size: 12, color: SojornColors.textDisabled), const SizedBox(width: 4), Text( '${_activeNeighborhoodMemberCount > 0 ? _activeNeighborhoodMemberCount : hubCluster.memberCount} members', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11), ), ], ), ], ), ), if (isAdmin) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: AppTheme.brightNavy.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6)), child: Text('ADMIN', style: TextStyle(color: AppTheme.brightNavy, fontSize: 10, fontWeight: FontWeight.bold)), ), IconButton( onPressed: _showNeighborhoodPicker, icon: Icon(Icons.tune, size: 18, color: SojornColors.textDisabled), tooltip: 'Neighborhood settings', ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(12, 2, 12, 10), child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: AppTheme.scaffoldBg, borderRadius: BorderRadius.circular(999), border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)), ), child: Row( children: [ Expanded( child: _buildHubSegment( label: 'Feed', icon: Icons.rss_feed, selected: _activeNeighborhoodHubTab == NeighborhoodHubTab.feed, onTap: () => setState(() => _activeNeighborhoodHubTab = NeighborhoodHubTab.feed), ), ), Expanded( child: _buildHubSegment( label: 'Chat', icon: Icons.chat_bubble_outline, badgeCount: _chatActivityCount, selected: _activeNeighborhoodHubTab == NeighborhoodHubTab.chat, onTap: () => setState(() => _activeNeighborhoodHubTab = NeighborhoodHubTab.chat), ), ), Expanded( child: _buildHubSegment( label: 'Forum', icon: Icons.forum_outlined, badgeCount: _forumActivityCount, selected: _activeNeighborhoodHubTab == NeighborhoodHubTab.forum, onTap: () => setState(() => _activeNeighborhoodHubTab = NeighborhoodHubTab.forum), ), ), Expanded( child: _buildHubSegment( label: 'Members', icon: Icons.groups_2_outlined, selected: _activeNeighborhoodHubTab == NeighborhoodHubTab.members, onTap: () => setState(() => _activeNeighborhoodHubTab = NeighborhoodHubTab.members), ), ), ], ), ), ), ], ), ), Expanded( child: switch (_activeNeighborhoodHubTab) { NeighborhoodHubTab.feed => _buildNeighborhoodFeedPane(groupName), NeighborhoodHubTab.chat => GroupChatTab( groupId: groupId, currentUserId: AuthService.instance.currentUser?.id, ), NeighborhoodHubTab.forum => GroupForumTab(groupId: groupId), NeighborhoodHubTab.members => GroupMembersTab( groupId: groupId, group: hubCluster, isEncrypted: false, ), }, ), ], ); } Widget _buildHubSegment({ required String label, required IconData icon, required bool selected, required VoidCallback onTap, int badgeCount = 0, }) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 160), curve: Curves.easeOut, padding: const EdgeInsets.symmetric(vertical: 7), decoration: BoxDecoration( color: selected ? AppTheme.navyBlue : Colors.transparent, borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: selected ? SojornColors.basicWhite : SojornColors.textDisabled), const SizedBox(width: 5), Text( label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: selected ? SojornColors.basicWhite : SojornColors.textDisabled, ), ), if (badgeCount > 0) ...[ const SizedBox(width: 5), Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: selected ? SojornColors.basicWhite : AppTheme.brightNavy, borderRadius: BorderRadius.circular(999), ), child: Text( badgeCount > 9 ? '9+' : '$badgeCount', style: TextStyle( color: selected ? AppTheme.navyBlue : SojornColors.basicWhite, fontSize: 9, fontWeight: FontWeight.w800, ), ), ), ], ], ), ), ); } Widget _buildNeighborhoodFeedPane(String groupName) { return Column( children: [ Container( margin: const EdgeInsets.fromLTRB(12, 10, 12, 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(10), onTap: _onCreateBoardPost, child: Row( children: [ CircleAvatar( radius: 14, backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.12), child: Icon(Icons.edit, size: 14, color: AppTheme.brightNavy.withValues(alpha: 0.8)), ), const SizedBox(width: 10), Expanded( child: Text( "What's happening in $groupName?", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.textDisabled, fontSize: 13), ), ), FilledButton( onPressed: _onCreateBoardPost, style: FilledButton.styleFrom( backgroundColor: AppTheme.navyBlue, foregroundColor: SojornColors.basicWhite, minimumSize: const Size(0, 30), padding: const EdgeInsets.symmetric(horizontal: 12), visualDensity: VisualDensity.compact, ), child: const Text('Post', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), ), ], ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), color: AppTheme.scaffoldBg, child: Row( children: [ Icon(Icons.filter_list, size: 14, color: SojornColors.textDisabled), const SizedBox(width: 6), _buildBoardSortChip('new', 'New'), const SizedBox(width: 6), _buildBoardSortChip('hot', 'Hot'), ], ), ), Expanded( child: _isLoadingBoard && _boardEntries.isEmpty ? const Center(child: CircularProgressIndicator()) : _boardEntries.isEmpty ? Center( child: Text('No posts yet in $groupName', style: TextStyle(color: SojornColors.textDisabled)), ) : RefreshIndicator( onRefresh: _loadBoardEntries, child: ListView.separated( padding: const EdgeInsets.all(12), itemCount: _boardEntries.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, i) => _buildBoardEntryCard(_boardEntries[i]), ), ), ), ], ); } Widget _buildBoardFilterChip(BoardTopic? topic, String label, IconData icon, Color color) { final isSelected = _selectedBoardTopic == topic; return Material( color: Colors.transparent, child: FilterChip( selected: isSelected, label: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: isSelected ? SojornColors.basicWhite : color), const SizedBox(width: 4), Text(label), ], ), labelStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: isSelected ? SojornColors.basicWhite : color, ), selectedColor: color, backgroundColor: color.withValues(alpha: 0.08), side: BorderSide(color: color.withValues(alpha: 0.2)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), showCheckmark: false, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, onSelected: (_) { setState(() => _selectedBoardTopic = isSelected ? null : topic); _loadBoardEntries(); }, ), ); } Widget _buildBoardEntryCard(BoardEntry entry) { final topicColor = entry.topic.color; final authorName = entry.authorDisplayName.isNotEmpty ? entry.authorDisplayName : entry.authorHandle; final avatarInitial = authorName.isNotEmpty ? authorName[0].toUpperCase() : 'N'; return GestureDetector( onTap: () async { final updated = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => BoardEntryDetailScreen(entry: entry)), ); if (updated != null && mounted) { setState(() { final idx = _boardEntries.indexWhere((e) => e.id == updated.id); if (idx >= 0) _boardEntries[idx] = updated; }); } }, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: entry.isPinned ? AppTheme.brightNavy.withValues(alpha: 0.03) : AppTheme.cardSurface, borderRadius: BorderRadius.circular(12), border: Border.all( color: entry.isPinned ? AppTheme.brightNavy.withValues(alpha: 0.24) : AppTheme.navyBlue.withValues(alpha: 0.08), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (entry.isPinned) ...[ Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppTheme.brightNavy.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.push_pin, size: 12, color: AppTheme.brightNavy), const SizedBox(width: 4), Text('Pinned by moderators', style: TextStyle(fontSize: 11, color: AppTheme.brightNavy, fontWeight: FontWeight.w700)), ], ), ), ], // Header: topic badge + time Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: topicColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(entry.topic.icon, size: 12, color: topicColor), const SizedBox(width: 4), Text(entry.topic.displayName, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: topicColor)), ]), ), if (entry.isPinned) ...[ const SizedBox(width: 6), Icon(Icons.push_pin, size: 12, color: AppTheme.brightNavy), ], const Spacer(), Text(entry.getTimeAgo(), style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), ], ), const SizedBox(height: 8), // User row: avatar + name + resident badge Row( children: [ CircleAvatar( radius: 12, backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.12), backgroundImage: entry.authorAvatarUrl.isNotEmpty ? NetworkImage(entry.authorAvatarUrl) : null, child: entry.authorAvatarUrl.isEmpty ? Text( avatarInitial, style: TextStyle(color: AppTheme.brightNavy, fontSize: 11, fontWeight: FontWeight.w700), ) : null, ), const SizedBox(width: 8), Expanded( child: Row( children: [ Text( authorName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.postContent, fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: AppTheme.brightNavy.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(999), ), child: Text( 'Resident', style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: AppTheme.brightNavy), ), ), ], ), ), ], ), const SizedBox(height: 8), // Body Text(entry.body, maxLines: 4, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.postContentLight, fontSize: 14, height: 1.4)), // Image if (entry.imageUrl != null) ...[ const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network(entry.imageUrl!, height: 140, width: double.infinity, fit: BoxFit.cover), ), ], const SizedBox(height: 10), // Bottom action bar: upvote | comments | flag Row( children: [ // Upvote button Material( color: Colors.transparent, child: InkWell( onTap: () => _toggleBoardVote(entry), borderRadius: BorderRadius.circular(18), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), constraints: const BoxConstraints(minHeight: 36, minWidth: 48), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon( entry.hasVoted ? Icons.arrow_upward : Icons.arrow_upward_outlined, size: 16, color: entry.hasVoted ? AppTheme.brightNavy : SojornColors.textDisabled, ), const SizedBox(width: 4), Text('${entry.upvotes}', style: TextStyle( color: entry.hasVoted ? AppTheme.brightNavy : SojornColors.textDisabled, fontSize: 12, fontWeight: FontWeight.w600, )), ]), ), ), ), const SizedBox(width: 12), // Reply count Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), constraints: const BoxConstraints(minHeight: 36, minWidth: 48), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.chat_bubble_outline, size: 15, color: SojornColors.textDisabled), const SizedBox(width: 4), Text('${entry.replyCount}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 12, fontWeight: FontWeight.w600)), ]), ), const Spacer(), // Flag/Report button if (_isNeighborhoodAdmin) PopupMenuButton( icon: Icon(Icons.more_horiz, size: 16, color: SojornColors.textDisabled), onSelected: (val) { if (val == 'remove') _removeBoardEntry(entry); if (val == 'flag') _flagBoardEntry(entry); }, itemBuilder: (_) => [ const PopupMenuItem(value: 'flag', child: Text('Flag Content')), const PopupMenuItem(value: 'remove', child: Text('Remove (Admin)', style: TextStyle(color: SojornColors.destructive))), ], ) else IconButton( icon: Icon(Icons.flag_outlined, size: 16, color: SojornColors.textDisabled.withValues(alpha: 0.5)), onPressed: () => _flagBoardEntry(entry), tooltip: 'Report Content', constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), ], ), ], ), ), ); } // ─── Search view ─────────────────────────────────────────────────── Widget _buildSearchView() { final hasResults = _searchBeacons.isNotEmpty || _searchBoard.isNotEmpty || _searchGroups.isNotEmpty; final hasQuery = _searchController.text.trim().isNotEmpty; return Container( color: AppTheme.scaffoldBg, child: Column( children: [ // Search input Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 8), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), ), child: Material( color: Colors.transparent, child: TextField( controller: _searchController, onChanged: _onSearchChanged, style: TextStyle(color: SojornColors.postContent, fontSize: 14), decoration: InputDecoration( hintText: 'Search beacons, boards, groups…', hintStyle: TextStyle(color: SojornColors.textDisabled), prefixIcon: Icon(Icons.search, size: 20, color: SojornColors.textDisabled), suffixIcon: hasQuery ? IconButton( icon: Icon(Icons.close, size: 18, color: SojornColors.textDisabled), onPressed: () { _searchController.clear(); _onSearchChanged(''); }, ) : null, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), ), ), ), ), // Results Expanded( child: _isSearching ? const Center(child: CircularProgressIndicator()) : !hasQuery ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.sensors, color: AppTheme.navyBlue.withValues(alpha: 0.15), size: 56), const SizedBox(height: 12), Text('Search the beacon ecosystem', style: TextStyle(color: AppTheme.navyBlue.withValues(alpha: 0.4), fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('Find incidents, board posts, and public groups', style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)), ], ), ) : !hasResults ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.search_off, color: AppTheme.navyBlue.withValues(alpha: 0.2), size: 48), const SizedBox(height: 12), Text('No results found', style: TextStyle(color: AppTheme.navyBlue.withValues(alpha: 0.4), fontSize: 15, fontWeight: FontWeight.w600)), ], ), ) : ListView( padding: const EdgeInsets.symmetric(horizontal: 16), children: [ if (_searchBeacons.isNotEmpty) ...[ _searchSectionHeader('Beacons', Icons.sensors, _searchBeacons.length), ..._searchBeacons.map(_buildBeaconResult), ], if (_searchBoard.isNotEmpty) ...[ _searchSectionHeader('Board Posts', Icons.forum, _searchBoard.length), ..._searchBoard.map(_buildBoardResult), ], if (_searchGroups.isNotEmpty) ...[ _searchSectionHeader('Public Groups', Icons.groups, _searchGroups.length), ..._searchGroups.map(_buildGroupResult), ], const SizedBox(height: 16), ], ), ), ], ), ); } Widget _searchSectionHeader(String title, IconData icon, int count) { return Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Row( children: [ Icon(icon, size: 16, color: AppTheme.brightNavy), const SizedBox(width: 8), Text(title, style: TextStyle(color: AppTheme.navyBlue, fontSize: 13, fontWeight: FontWeight.w700)), const SizedBox(width: 6), Text('($count)', style: TextStyle(color: SojornColors.textDisabled, fontSize: 12)), ], ), ); } Widget _buildBeaconResult(Map b) { return Card( margin: const EdgeInsets.only(bottom: 8), color: AppTheme.cardSurface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), child: ListTile( dense: true, leading: Icon(Icons.warning_amber, color: Colors.orange, size: 22), title: Text(b['body'] ?? '', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.postContent, fontSize: 13)), subtitle: Text('${b['category'] ?? 'incident'} · ${b['author_handle'] ?? ''}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), onTap: () { // Navigate to beacon detail if possible }, ), ); } Widget _buildBoardResult(Map b) { final topic = b['topic'] as String? ?? 'community'; return Card( margin: const EdgeInsets.only(bottom: 8), color: AppTheme.cardSurface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), child: ListTile( dense: true, leading: Icon(Icons.forum, color: AppTheme.brightNavy, size: 22), title: Text(b['body'] ?? '', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.postContent, fontSize: 13)), subtitle: Text('$topic · ${b['author_handle'] ?? ''}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.arrow_upward, size: 12, color: SojornColors.textDisabled), Text('${b['upvotes'] ?? 0}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), ], ), onTap: () { final entry = BoardEntry.fromJson(b); Navigator.of(context).push(MaterialPageRoute( builder: (_) => BoardEntryDetailScreen(entry: entry), )); }, ), ); } Widget _buildGroupResult(Map g) { return Card( margin: const EdgeInsets.only(bottom: 8), color: AppTheme.cardSurface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), child: ListTile( dense: true, leading: CircleAvatar( radius: 18, backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1), backgroundImage: (g['avatar_url'] as String?)?.isNotEmpty == true ? NetworkImage(g['avatar_url'] as String) : null, child: (g['avatar_url'] as String?)?.isNotEmpty != true ? Icon(Icons.groups, size: 18, color: AppTheme.brightNavy) : null, ), title: Text(g['name'] ?? '', style: TextStyle(color: SojornColors.postContent, fontSize: 13, fontWeight: FontWeight.w600)), subtitle: Text('${g['member_count'] ?? 0} members · ${g['type'] ?? ''}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), onTap: () { final cluster = Cluster.fromJson(g); _navigateToCluster(cluster); }, ), ); } // ─── Groups view (clusters + capsules) ────────────────────────────── Widget _buildGroupsView() { var filtered = _selectedGroupCategory == null ? _clusters : _clusters.where((c) => c.category == _selectedGroupCategory).toList(); // Filter out current neighborhood board group if (_neighborhood != null && _neighborhood!['group_id'] != null) { final hoodGroupId = _neighborhood!['group_id']; filtered = filtered.where((c) => c.id != hoodGroupId).toList(); } final neighborhoods = filtered.where((c) => !c.isCapsule).toList(); final capsules = filtered.where((c) => c.isCapsule).toList(); return Container( color: AppTheme.scaffoldBg, child: Column( children: [ Container( padding: const EdgeInsets.fromLTRB(20, 12, 12, 12), decoration: BoxDecoration( color: AppTheme.cardSurface, border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))), ), child: Row( children: [ Icon(Icons.groups, color: AppTheme.brightNavy, size: 20), const SizedBox(width: 10), Expanded( child: Text('Groups', style: TextStyle(color: AppTheme.navyBlue, fontSize: 17, fontWeight: FontWeight.w700)), ), PopupMenuButton( icon: Icon(Icons.add, color: AppTheme.brightNavy), onSelected: (val) { if (val == 'group') _showCreateGroupSheet(); if (val == 'capsule') _showCreateCapsuleSheet(); }, itemBuilder: (_) => [ const PopupMenuItem(value: 'group', child: Text('New Group')), const PopupMenuItem(value: 'capsule', child: Text('New Capsule')), ], ), ], ), ), // Category filters Container( color: AppTheme.cardSurface, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row( children: [ Icon(Icons.swipe, size: 13, color: SojornColors.textDisabled), const SizedBox(width: 4), Text('Swipe for more categories', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)), ], ), ), SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ _buildGroupCategoryChip(null, 'All', Icons.grid_view, AppTheme.brightNavy), const SizedBox(width: 6), ...GroupCategory.values.map((cat) => Padding( padding: const EdgeInsets.only(right: 6), child: _buildGroupCategoryChip(cat, cat.displayName, cat.icon, cat.color), )), ], ), ), ], ), ), Expanded( child: _isLoadingClusters ? const Center(child: CircularProgressIndicator()) : _clusters.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.group_add, color: AppTheme.navyBlue.withValues(alpha: 0.2), size: 48), const SizedBox(height: 12), Text('No groups yet', style: TextStyle( color: AppTheme.navyBlue.withValues(alpha: 0.4), fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('Create or join a group to get started', style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)), const SizedBox(height: 20), Row( mainAxisSize: MainAxisSize.min, children: [ OutlinedButton.icon( onPressed: _showCreateGroupSheet, icon: const Icon(Icons.location_on, size: 16), label: const Text('New Group'), style: OutlinedButton.styleFrom( foregroundColor: AppTheme.brightNavy, side: BorderSide(color: AppTheme.brightNavy.withValues(alpha: 0.3)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ), const SizedBox(width: 10), OutlinedButton.icon( onPressed: _showCreateCapsuleSheet, icon: const Icon(Icons.lock, size: 16), label: const Text('New Capsule'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF4CAF50), side: BorderSide(color: const Color(0xFF4CAF50).withValues(alpha: 0.3)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ), ], ), ], ), ) : RefreshIndicator( onRefresh: _loadClusters, child: ListView( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), children: [ if (neighborhoods.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(4, 8, 4, 8), child: Text('NEIGHBORHOODS', style: TextStyle( color: AppTheme.navyBlue.withValues(alpha: 0.4), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1, )), ), ...neighborhoods.map((c) => _buildClusterCard(c, isCapsule: false)), ], if (capsules.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), child: Row( children: [ Icon(Icons.lock, size: 12, color: const Color(0xFF4CAF50)), const SizedBox(width: 6), Text('CAPSULES', style: TextStyle( color: const Color(0xFF2E7D32), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1, )), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: const Color(0xFF4CAF50).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: const Text( 'E2E', style: TextStyle( color: Color(0xFF2E7D32), fontSize: 9, fontWeight: FontWeight.w700, ), ), ), ], ), ), ...capsules.map((c) => _buildClusterCard(c, isCapsule: true)), ], const SizedBox(height: 16), ], ), ), ), ], ), ); } Widget _buildGroupCategoryChip(GroupCategory? category, String label, IconData icon, Color color) { final isSelected = _selectedGroupCategory == category; final selectedColor = AppTheme.navyBlue; return Material( color: Colors.transparent, child: FilterChip( selected: isSelected, label: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 20, height: 20, decoration: BoxDecoration( color: isSelected ? SojornColors.basicWhite.withValues(alpha: 0.18) : color.withValues(alpha: 0.14), shape: BoxShape.circle, ), child: Icon(icon, size: 13, color: isSelected ? SojornColors.basicWhite : color), ), const SizedBox(width: 6), Text(label), ], ), labelStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: isSelected ? SojornColors.basicWhite : AppTheme.navyBlue, ), selectedColor: selectedColor, backgroundColor: AppTheme.cardSurface, side: BorderSide(color: isSelected ? selectedColor : AppTheme.navyBlue.withValues(alpha: 0.14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), elevation: isSelected ? 1 : 0, shadowColor: selectedColor.withValues(alpha: 0.25), showCheckmark: false, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, onSelected: (_) { setState(() => _selectedGroupCategory = isSelected ? null : category); }, ), ); } Widget _buildClusterCard(Cluster cluster, {required bool isCapsule}) { final capsuleGreen = const Color(0xFF4CAF50); final capsuleDark = const Color(0xFF2E7D32); return GestureDetector( onTap: () => _navigateToCluster(cluster), child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( gradient: isCapsule ? LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ const Color(0xFFF4FBF4), const Color(0xFFE9F6EC), ], ) : null, color: isCapsule ? null : AppTheme.cardSurface, borderRadius: BorderRadius.circular(14), border: Border.all( color: isCapsule ? capsuleGreen.withValues(alpha: 0.32) : AppTheme.navyBlue.withValues(alpha: 0.08), ), boxShadow: isCapsule ? [ BoxShadow( color: capsuleGreen.withValues(alpha: 0.12), blurRadius: 10, offset: const Offset(0, 3), ), ] : null, ), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: isCapsule ? capsuleGreen.withValues(alpha: 0.1) : AppTheme.brightNavy.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Icon( isCapsule ? Icons.lock : Icons.location_on, color: isCapsule ? capsuleGreen : AppTheme.brightNavy, size: 20, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(cluster.name, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.navyBlue, )), const SizedBox(height: 3), Row(children: [ if (isCapsule) ...[ Icon(Icons.shield, size: 11, color: capsuleDark), const SizedBox(width: 3), Text('E2E Encrypted', style: TextStyle(fontSize: 10, color: capsuleDark, fontWeight: FontWeight.w600)), ] else ...[ Icon(Icons.public, size: 11, color: SojornColors.textDisabled), const SizedBox(width: 3), Text('Public', style: TextStyle(fontSize: 10, color: SojornColors.textDisabled)), ], const SizedBox(width: 8), Icon(Icons.people, size: 11, color: SojornColors.textDisabled), const SizedBox(width: 3), Text('${cluster.memberCount}', style: TextStyle(fontSize: 10, color: SojornColors.textDisabled)), if (cluster.category != GroupCategory.general) ...[ const SizedBox(width: 8), Icon(cluster.category.icon, size: 11, color: cluster.category.color.withValues(alpha: 0.7)), const SizedBox(width: 3), Text(cluster.category.displayName, style: TextStyle(fontSize: 10, color: cluster.category.color.withValues(alpha: 0.7))), ], ]), ], ), ), Icon(Icons.chevron_right, size: 18, color: SojornColors.textDisabled), ], ), ), ); } void _showCreateGroupSheet() { showModalBottomSheet( context: context, backgroundColor: AppTheme.cardSurface, isScrollControlled: true, builder: (ctx) => _CreateGroupInline(onCreated: () { Navigator.pop(ctx); _loadClusters(); }), ); } void _showCreateCapsuleSheet() { showModalBottomSheet( context: context, backgroundColor: AppTheme.cardSurface, isScrollControlled: true, builder: (ctx) => _CreateCapsuleInline(onCreated: () { Navigator.pop(ctx); _loadClusters(); }), ); } Widget _buildDragHandle() { return GestureDetector( onTap: () { final currentSize = _sheetController.size; if (currentSize < 0.3) { _sheetController.animateTo(0.5, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } else { _sheetController.animateTo(0.15, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } }, child: Container( padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), child: Column( children: [ Container( width: 40, height: 4, decoration: BoxDecoration( color: AppTheme.navyBlue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 8), Row( children: [ Icon(Icons.radar, color: AppTheme.brightNavy.withValues(alpha: 0.6), size: 16), const SizedBox(width: 6), Text( 'RADAR', style: TextStyle( color: AppTheme.navyBlue.withValues(alpha: 0.5), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1.5, ), ), const Spacer(), Text( '${_beaconModels.where((b) => b.beaconType.isGeoAlert).length} alerts', style: TextStyle(color: AppTheme.navyBlue.withValues(alpha: 0.4), fontSize: 11), ), Icon(Icons.keyboard_arrow_up, color: AppTheme.navyBlue.withValues(alpha: 0.3), size: 18), ], ), ], ), ), ); } Widget _buildIncidentCard(Post post) { final beacon = post.toBeacon(); final severityColor = beacon.pinColor; final isRecent = beacon.isRecent; return GestureDetector( onTap: () => _onMarkerTap(post), child: Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(12), border: Border.all( color: isRecent ? severityColor.withValues(alpha: 0.5) : AppTheme.navyBlue.withValues(alpha: 0.08), ), ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: severityColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon(beacon.beaconType.icon, color: severityColor, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text(beacon.beaconType.displayName, style: TextStyle(color: AppTheme.navyBlue, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis), ), if (isRecent) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: SojornColors.destructive.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: const Text('LIVE', style: TextStyle(color: SojornColors.destructive, fontSize: 9, fontWeight: FontWeight.bold)), ), ], ), const SizedBox(height: 3), Text(beacon.body, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.postContentLight, fontSize: 12)), const SizedBox(height: 4), Row( children: [ Icon(Icons.schedule, size: 11, color: SojornColors.textDisabled), const SizedBox(width: 3), Text(beacon.getTimeAgo(), style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), const SizedBox(width: 10), Icon(Icons.location_on, size: 11, color: SojornColors.textDisabled), const SizedBox(width: 3), Text(beacon.getFormattedDistance(), style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), const Spacer(), Icon(Icons.visibility, size: 11, color: SojornColors.textDisabled), const SizedBox(width: 3), Text('${beacon.verificationCount}', style: TextStyle(color: SojornColors.textDisabled, fontSize: 11)), ], ), ], ), ), ], ), ), ); } Widget _buildMap() { return FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: _mapCenter, initialZoom: _currentZoom, onPositionChanged: _onMapPositionChanged, minZoom: 3.0, maxZoom: 19.0, ), children: [ TileLayer( urlTemplate: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', subdomains: const ['a', 'b', 'c', 'd'], userAgentPackageName: 'com.sojorn.app', retinaMode: RetinaMode.isHighDensity(context), ), MarkerLayer( markers: [ // Only geo-alert beacons on the map (not discussions) ..._beacons .where((p) => p.beaconType?.isGeoAlert ?? false) .map((beacon) => _createMarker(beacon)), if (_locationPermissionGranted && _userLocation != null) _createUserLocationMarker(), ], ), ], ); } Widget _buildLocationPermissionOverlay(BuildContext context) { return Container( color: AppTheme.scaffoldBg, child: Center( child: Container( margin: const EdgeInsets.all(32), padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.shield, size: 64, color: AppTheme.brightNavy), const SizedBox(height: 24), Text('Beacon', style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center), const SizedBox(height: 8), Text('Community Safety Network', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: SojornColors.textDisabled), textAlign: TextAlign.center), const SizedBox(height: 20), Text('Location access is required to show safety alerts and connect you with your neighborhood.', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: SojornColors.postContentLight), textAlign: TextAlign.center), const SizedBox(height: 24), Material( color: Colors.transparent, child: ElevatedButton.icon( onPressed: _isLoadingLocation ? null : _requestLocationPermission, icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_on), label: const Text('Enable Location'), style: ElevatedButton.styleFrom(backgroundColor: AppTheme.brightNavy, foregroundColor: SojornColors.basicWhite), ), ), ], ), ), ), ); } Marker _createMarker(Post post) { final beacon = post.toBeacon(); final severityColor = beacon.pinColor; final typeIcon = beacon.beaconType.icon; final isRecent = beacon.isRecent; final fallbackBase = _userLocation ?? _mapCenter; final markerPosition = (post.latitude != null && post.longitude != null) ? LatLng(post.latitude!, post.longitude!) : LatLng( fallbackBase.latitude + ((post.distanceMeters ?? 0) / 111000), fallbackBase.longitude + ((post.distanceMeters ?? 0) / 111000), ); return Marker( point: markerPosition, width: 48, height: 48, child: GestureDetector( onTap: () => _onMarkerTap(post), child: _SeverityMarker( color: severityColor, icon: typeIcon, isRecent: isRecent, ), ), ); } Marker _createUserLocationMarker() { return Marker( point: _userLocation!, width: 40, height: 40, child: _PulsingLocationIndicator(), ); } } // ─── Severity-colored Marker with pulse for recent ───────────── class _SeverityMarker extends StatefulWidget { final Color color; final IconData icon; final bool isRecent; const _SeverityMarker({ required this.color, required this.icon, required this.isRecent, }); @override State<_SeverityMarker> createState() => _SeverityMarkerState(); } class _SeverityMarkerState extends State<_SeverityMarker> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _pulseAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1200), vsync: this, ); if (widget.isRecent) { _pulseAnimation = Tween(begin: 0.85, end: 1.15).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); _controller.repeat(reverse: true); } else { _pulseAnimation = const AlwaysStoppedAnimation(1.0); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color, boxShadow: [ BoxShadow( color: widget.color.withValues(alpha: widget.isRecent ? 0.6 : 0.3), blurRadius: widget.isRecent ? 12 : 6, spreadRadius: widget.isRecent ? 3 : 0, ), ], ), child: Icon(widget.icon, color: SojornColors.basicWhite, size: 26), ), ); }, ); } } // ─── Pulsing user location dot ───────────── class _PulsingLocationIndicator extends StatefulWidget { @override State<_PulsingLocationIndicator> createState() => _PulsingLocationIndicatorState(); } class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, )..repeat(reverse: false); _animation = Tween(begin: 0.4, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return Stack( alignment: Alignment.center, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF2196F3).withValues(alpha: 0.3 * (1 - _animation.value)), border: Border.all(color: const Color(0xFF2196F3).withValues(alpha: 0.5 * (1 - _animation.value)), width: 2), ), ), Container( width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF2196F3), border: Border.all(color: SojornColors.basicWhite, width: 3), boxShadow: [BoxShadow(color: const Color(0x4D000000), blurRadius: 4, spreadRadius: 1)], ), ), ], ); }, ); } } // ─── Create Group inline form ───────────────────────────────────────── class _CreateGroupInline extends StatefulWidget { final VoidCallback onCreated; const _CreateGroupInline({required this.onCreated}); @override State<_CreateGroupInline> createState() => _CreateGroupInlineState(); } class _CreateGroupInlineState extends State<_CreateGroupInline> { final _nameCtrl = TextEditingController(); final _descCtrl = TextEditingController(); String _privacy = 'public'; GroupCategory _category = GroupCategory.general; bool _submitting = false; @override void dispose() { _nameCtrl.dispose(); _descCtrl.dispose(); super.dispose(); } Future _submit() async { if (_nameCtrl.text.trim().isEmpty) return; setState(() => _submitting = true); try { await ApiService.instance.createGroup( name: _nameCtrl.text.trim(), description: _descCtrl.text.trim(), privacy: _privacy, category: _category.value, ); widget.onCreated(); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); } } if (mounted) setState(() => _submitting = false); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, MediaQuery.of(context).viewInsets.bottom + 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center(child: Container(width: 40, height: 4, decoration: BoxDecoration( color: SojornColors.basicBlack.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), const SizedBox(height: 20), const Text('Create Group', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), const SizedBox(height: 16), Material( color: Colors.transparent, child: TextField( controller: _nameCtrl, decoration: InputDecoration( labelText: 'Group name', filled: true, fillColor: SojornColors.basicBlack.withValues(alpha: 0.04), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: SojornColors.basicBlack.withValues(alpha: 0.1))), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: SojornColors.basicBlack.withValues(alpha: 0.1))), ), ), ), const SizedBox(height: 12), Material( color: Colors.transparent, child: TextField( controller: _descCtrl, maxLines: 2, decoration: InputDecoration( labelText: 'Description (optional)', filled: true, fillColor: SojornColors.basicBlack.withValues(alpha: 0.04), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: SojornColors.basicBlack.withValues(alpha: 0.1))), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: SojornColors.basicBlack.withValues(alpha: 0.1))), ), ), ), const SizedBox(height: 14), Row(children: [ Text('Visibility:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))), const SizedBox(width: 12), ChoiceChip(label: const Text('Public'), selected: _privacy == 'public', onSelected: (_) => setState(() => _privacy = 'public'), selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)), const SizedBox(width: 8), ChoiceChip(label: const Text('Private'), selected: _privacy == 'private', onSelected: (_) => setState(() => _privacy = 'private'), selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)), ]), const SizedBox(height: 14), Text('Category:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))), const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: GroupCategory.values.map((cat) => ChoiceChip( label: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(cat.icon, size: 14, color: _category == cat ? SojornColors.basicWhite : cat.color), const SizedBox(width: 4), Text(cat.displayName), ], ), selected: _category == cat, onSelected: (_) => setState(() => _category = cat), selectedColor: cat.color, labelStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _category == cat ? SojornColors.basicWhite : cat.color, ), backgroundColor: cat.color.withValues(alpha: 0.08), side: BorderSide(color: cat.color.withValues(alpha: 0.2)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), showCheckmark: false, visualDensity: VisualDensity.compact, )).toList(), ), const SizedBox(height: 20), SizedBox( width: double.infinity, height: 48, child: ElevatedButton( onPressed: _submitting ? null : _submit, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.navyBlue, foregroundColor: SojornColors.basicWhite, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: _submitting ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) : const Text('Create Group', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), ), ), ], ), ); } } // ─── Create Capsule inline form ─────────────────────────────────────── class _CreateCapsuleInline extends StatefulWidget { final VoidCallback onCreated; const _CreateCapsuleInline({required this.onCreated}); @override State<_CreateCapsuleInline> createState() => _CreateCapsuleInlineState(); } class _CreateCapsuleInlineState extends State<_CreateCapsuleInline> { final _nameCtrl = TextEditingController(); final _descCtrl = TextEditingController(); bool _submitting = false; String? _statusMsg; @override void dispose() { _nameCtrl.dispose(); _descCtrl.dispose(); super.dispose(); } Future _submit() async { if (_nameCtrl.text.trim().isEmpty) return; setState(() { _submitting = true; _statusMsg = 'Generating encryption keys…'; }); try { final capsuleKey = await CapsuleSecurityService.generateCapsuleKey(); final publicKeyB64 = await CapsuleSecurityService.getUserPublicKeyB64(); setState(() => _statusMsg = 'Encrypting group key…'); final encryptedGroupKey = await CapsuleSecurityService.encryptCapsuleKeyForUser( capsuleKey: capsuleKey, recipientPublicKeyB64: publicKeyB64); setState(() => _statusMsg = 'Creating capsule…'); final result = await ApiService.instance.createCapsule( name: _nameCtrl.text.trim(), description: _descCtrl.text.trim(), publicKey: publicKeyB64, encryptedGroupKey: encryptedGroupKey); final capsuleId = (result['capsule'] as Map?)?['id']?.toString(); if (capsuleId != null) { await CapsuleSecurityService.cacheCapsuleKey(capsuleId, capsuleKey); } widget.onCreated(); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); } if (mounted) setState(() { _submitting = false; _statusMsg = null; }); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, MediaQuery.of(context).viewInsets.bottom + 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center(child: Container(width: 40, height: 4, decoration: BoxDecoration( color: AppTheme.navyBlue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), const SizedBox(height: 20), Row(children: [ const Icon(Icons.lock, color: Color(0xFF4CAF50), size: 20), const SizedBox(width: 8), Text('Create Private Capsule', style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)), ]), const SizedBox(height: 4), Text('End-to-end encrypted. The server never sees your content.', style: TextStyle(fontSize: 12, color: SojornColors.textDisabled)), const SizedBox(height: 20), Material( color: Colors.transparent, child: TextField( controller: _nameCtrl, style: TextStyle(color: SojornColors.postContent), decoration: InputDecoration( labelText: 'Capsule name', labelStyle: TextStyle(color: SojornColors.textDisabled), filled: true, fillColor: AppTheme.scaffoldBg, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), ), ), ), const SizedBox(height: 12), Material( color: Colors.transparent, child: TextField( controller: _descCtrl, maxLines: 2, style: TextStyle(color: SojornColors.postContent), decoration: InputDecoration( labelText: 'Description (optional)', labelStyle: TextStyle(color: SojornColors.textDisabled), filled: true, fillColor: AppTheme.scaffoldBg, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), ), ), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFF4CAF50).withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF4CAF50).withValues(alpha: 0.15)), ), child: Row(children: [ Icon(Icons.shield, size: 14, color: const Color(0xFF4CAF50).withValues(alpha: 0.7)), const SizedBox(width: 8), Expanded(child: Text('Keys are generated on your device. Only invited members can decrypt content.', style: TextStyle(fontSize: 11, color: SojornColors.postContentLight))), ]), ), const SizedBox(height: 20), SizedBox( width: double.infinity, height: 48, child: ElevatedButton( onPressed: _submitting ? null : _submit, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF4CAF50), foregroundColor: SojornColors.basicWhite, disabledBackgroundColor: const Color(0xFF4CAF50).withValues(alpha: 0.3), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: _submitting ? Row(mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)), const SizedBox(width: 10), Text(_statusMsg ?? 'Creating…', style: const TextStyle(fontSize: 13)), ]) : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.lock, size: 16), SizedBox(width: 8), Text('Generate Keys & Create', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), ]), ), ), ], ), ); } }