sojorn/sojorn_app/lib/screens/beacon/beacon_screen.dart

2687 lines
106 KiB
Dart

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 '../../models/group.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<BeaconScreenState> globalKey = GlobalKey<BeaconScreenState>();
final LatLng? initialMapCenter;
BeaconScreen({this.initialMapCenter}) : super(key: globalKey);
@override
ConsumerState<BeaconScreen> createState() => BeaconScreenState();
}
class BeaconScreenState extends ConsumerState<BeaconScreen> with TickerProviderStateMixin {
static const List<BeaconTab> _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<Post> _beacons = [];
List<Beacon> _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<BoardEntry> _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<Cluster> _clusters = [];
bool _isLoadingClusters = false;
Map<String, String> _encryptedKeys = {};
GroupCategory? _selectedGroupCategory;
// Neighborhood detection state
Map<String, dynamic>? _neighborhood;
bool _isDetectingNeighborhood = false;
bool _neighborhoodDetected = false;
bool _homeNeighborhoodChecked = false;
// Beacon search state
final _searchController = TextEditingController();
bool _isSearching = false;
List<Map<String, dynamic>> _searchBeacons = [];
List<Map<String, dynamic>> _searchBoard = [];
List<Map<String, dynamic>> _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<void> _checkLocationPermission() async {
final status = await Permission.location.status;
if (mounted) {
setState(() => _locationPermissionGranted = status.isGranted);
if (status.isGranted) {
await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser);
await _loadBeacons();
}
}
}
Future<void> _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<void> _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<void> _fetchWeather(double lat, double lng) async {
try {
final weather = await _intelService.fetchWeather(lat, lng);
if (mounted) setState(() => _weather = weather);
} catch (_) {}
}
Future<void> _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<String, dynamic>?;
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<void> _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<void> _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<String, dynamic>?;
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<void> _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<void> _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<void> _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<Map<String, dynamic>>() ?? [];
_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<String, dynamic>?;
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<String, dynamic>?;
final nestedName = nested?['name'] as String?;
return (nestedName != null && nestedName.isNotEmpty) ? nestedName : 'Neighborhood';
}
Future<void> _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 = <String>{};
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<void> _removeBoardEntry(BoardEntry entry) async {
final reasonController = TextEditingController();
final confirm = await showDialog<bool>(
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<void> _flagBoardEntry(BoardEntry entry) async {
final reasonController = TextEditingController();
final confirm = await showDialog<bool>(
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<void> _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<Map<String, dynamic>>() ?? [];
_searchBoard = (data['board_entries'] as List?)?.cast<Map<String, dynamic>>() ?? [];
_searchGroups = (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
_isSearching = false;
});
}
} catch (e) {
if (kDebugMode) print('[BeaconSearch] Error: $e');
if (mounted) setState(() => _isSearching = false);
}
}
Future<void> _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<String>(
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<String>(
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<BoardEntry>(
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<String>(
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String>(
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<double> _pulseAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
if (widget.isRecent) {
_pulseAnimation = Tween<double>(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<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(reverse: false);
_animation = Tween<double>(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();
bool _privacy = false;
GroupCategory _category = GroupCategory.general;
bool _submitting = false;
@override
void dispose() { _nameCtrl.dispose(); _descCtrl.dispose(); super.dispose(); }
Future<void> _submit() async {
if (_nameCtrl.text.trim().isEmpty) return;
setState(() => _submitting = true);
try {
final api = ref.read(apiServiceProvider);
await api.createGroup(
name: _nameCtrl.text.trim(),
description: _descCtrl.text.trim(),
category: GroupCategory.fromString(_category.value),
isPrivate: _privacy,
);
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<void> _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<String, dynamic>?)?['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)),
]),
),
),
],
),
);
}
}