diff --git a/sojorn_app/lib/models/post.dart b/sojorn_app/lib/models/post.dart index eeb2bdf..6f0b5e0 100644 --- a/sojorn_app/lib/models/post.dart +++ b/sojorn_app/lib/models/post.dart @@ -78,6 +78,12 @@ class Post { final double? confidenceScore; final bool? isActiveBeacon; final String? beaconStatusColor; + final String? severity; + final String? incidentStatus; + final int? radius; + final int? verificationCount; + final int? vouchCount; + final int? reportCount; final double? latitude; final double? longitude; @@ -140,6 +146,12 @@ class Post { this.confidenceScore, this.isActiveBeacon, this.beaconStatusColor, + this.severity, + this.incidentStatus, + this.radius, + this.verificationCount, + this.vouchCount, + this.reportCount, this.latitude, this.longitude, this.distanceMeters, @@ -288,6 +300,12 @@ class Post { confidenceScore: _parseDouble(json['confidence_score']), isActiveBeacon: json['is_active_beacon'] as bool?, beaconStatusColor: json['status_color'] as String?, + severity: json['severity'] as String?, + incidentStatus: json['incident_status'] as String?, + radius: _parseInt(json['radius']), + verificationCount: _parseInt(json['verification_count']), + vouchCount: _parseInt(json['vouch_count']), + reportCount: _parseInt(json['report_count']), latitude: _parseLatitude(json), longitude: _parseLongitude(json), distanceMeters: _parseDouble(json['distance_meters']), @@ -471,9 +489,13 @@ extension PostBeaconExtension on Post { authorHandle: author?.handle, authorDisplayName: author?.displayName, authorAvatarUrl: author?.avatarUrl, - vouchCount: null, - reportCount: null, + vouchCount: vouchCount, + reportCount: reportCount, userVote: null, + severity: BeaconSeverity.fromString(severity ?? 'medium'), + incidentStatus: BeaconIncidentStatus.fromString(incidentStatus ?? 'active'), + radius: radius ?? 500, + verificationCount: verificationCount ?? 0, ); } } diff --git a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart index e974d46..b166015 100644 --- a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart +++ b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart @@ -19,130 +19,158 @@ class _BeaconBottomSheetState extends ConsumerState { @override Widget build(BuildContext context) { final post = widget.post; - final theme = Theme.of(context); - final accentColor = theme.primaryColor; - - // Determine status color from confidence score - final confidenceScore = post.confidenceScore ?? 0.5; - Color statusColor; - String statusLabel; - if (confidenceScore > 0.7) { - statusColor = Colors.green; - statusLabel = 'Confirmed'; - } else if (confidenceScore >= 0.3) { - statusColor = Colors.orange; - statusLabel = 'Use caution'; - } else { - statusColor = Colors.red; - statusLabel = 'Unconfirmed'; - } + final beacon = post.toBeacon(); + final severityColor = beacon.pinColor; + final isRecent = beacon.isRecent; + final verCount = beacon.verificationCount; + final isVerified = verCount >= 3; return Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + decoration: const BoxDecoration( + color: Color(0xFF1A1A2E), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Status badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: statusColor), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(statusColor == Colors.green ? Icons.verified : Icons.warning, - size: 16, color: statusColor), - const SizedBox(width: 6), - Text( - statusLabel, - style: theme.textTheme.labelMedium?.copyWith( - color: statusColor, - fontWeight: FontWeight.bold, - ), - ), - ], + // Handle + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), ), ), const SizedBox(height: 16), - // Beacon type - Text( - post.beaconType?.displayName ?? 'Community', - style: theme.textTheme.labelMedium?.copyWith( - color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), - ), + + // Severity + LIVE badges row + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: severityColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: severityColor.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(beacon.severity.icon, size: 14, color: severityColor), + const SizedBox(width: 4), + Text(beacon.severity.label, + style: TextStyle(color: severityColor, fontWeight: FontWeight.bold, fontSize: 11)), + ], + ), + ), + const SizedBox(width: 8), + if (isRecent) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)), + child: const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ), + const Spacer(), + // Verification status + Icon(isVerified ? Icons.verified : Icons.pending, + color: isVerified ? Colors.green : Colors.amber, size: 18), + const SizedBox(width: 4), + Text('$verCount/3', + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 11)), + ], ), - const SizedBox(height: 4), + const SizedBox(height: 14), + + // Beacon type + icon + Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: severityColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(beacon.beaconType.icon, color: severityColor, size: 20), + ), + const SizedBox(width: 10), + Expanded( + child: Text(beacon.beaconType.displayName, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 10), + // Body - Text( - post.body, - style: theme.textTheme.bodyLarge, + Text(post.body, maxLines: 3, overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 14, height: 1.4)), + const SizedBox(height: 10), + + // Meta + Row( + children: [ + Icon(Icons.schedule, size: 12, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text(beacon.getTimeAgo(), style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + const SizedBox(width: 12), + Icon(Icons.location_on, size: 12, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text(beacon.getFormattedDistance(), style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + const SizedBox(width: 12), + Icon(Icons.radar, size: 12, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text('${beacon.radius}m', style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + ], ), - const SizedBox(height: 12), - Text( - '${_getFormattedDistance(post.distanceMeters)} • ${_getTimeAgo(post.createdAt)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 20), + const SizedBox(height: 18), + // Action buttons Row( children: [ Expanded( - child: OutlinedButton.icon( - onPressed: _isVouching ? null : () => _vouch(post.id), - icon: _isVouching - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.thumb_up, size: 18), - label: const Text('Vouch'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.green, - side: const BorderSide(color: Colors.green), + flex: 3, + child: SizedBox( + height: 42, + child: ElevatedButton.icon( + onPressed: _isVouching ? null : () => _vouch(post.id), + icon: _isVouching + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.visibility, size: 16), + label: Text(_isVouching ? '...' : 'I see this too', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade700, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + disabledBackgroundColor: Colors.green.withValues(alpha: 0.3), + ), ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Expanded( - child: OutlinedButton.icon( - onPressed: _isReporting ? null : () => _report(post.id), - icon: _isReporting - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.thumb_down, size: 18), - label: const Text('Report'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red), + flex: 2, + child: SizedBox( + height: 42, + child: OutlinedButton.icon( + onPressed: _isReporting ? null : () => _report(post.id), + icon: _isReporting + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red)) + : Icon(Icons.flag, size: 16, color: Colors.red.withValues(alpha: 0.7)), + label: Text(_isReporting ? '...' : 'False alarm', + style: TextStyle(color: Colors.red.withValues(alpha: 0.7), fontSize: 12)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Colors.red.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), ), ), ), ], ), - const SizedBox(height: 12), - // Confidence indicator - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: confidenceScore, - minHeight: 6, - backgroundColor: Colors.grey.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation(statusColor), - ), - ), - const SizedBox(height: 4), - Text( - 'Confidence ${(confidenceScore * 100).toStringAsFixed(0)}%', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), ], ), ); @@ -155,7 +183,7 @@ class _BeaconBottomSheetState extends ConsumerState { await apiService.vouchBeacon(beaconId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Thanks for confirming this beacon.')), + const SnackBar(content: Text('Thanks for confirming this report.'), backgroundColor: Colors.green), ); Navigator.of(context).pop(); } @@ -177,7 +205,7 @@ class _BeaconBottomSheetState extends ConsumerState { await apiService.reportBeacon(beaconId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Report received. Thanks for keeping the community safe.')), + const SnackBar(content: Text('Report received. Thanks for keeping the community safe.'), backgroundColor: Colors.orange), ); Navigator.of(context).pop(); } @@ -191,31 +219,4 @@ class _BeaconBottomSheetState extends ConsumerState { if (mounted) setState(() => _isReporting = false); } } - - /// Get human-readable distance - String _getFormattedDistance(double? distanceMeters) { - if (distanceMeters == null) return '0m'; - if (distanceMeters < 1000) { - return '${distanceMeters.round()}m'; - } else { - final km = distanceMeters / 1000; - return '${km.toStringAsFixed(1)}km'; - } - } - - /// Get time ago string - String _getTimeAgo(DateTime createdAt) { - final now = DateTime.now(); - final difference = now.difference(createdAt); - - if (difference.inMinutes < 1) { - return 'Just now'; - } else if (difference.inMinutes < 60) { - return '${difference.inMinutes}m ago'; - } else if (difference.inHours < 24) { - return '${difference.inHours}h ago'; - } else { - return '${difference.inDays}d ago'; - } - } } diff --git a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart index 6cb8436..d25394c 100644 --- a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart +++ b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart @@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/post.dart'; import '../../models/beacon.dart'; import '../../providers/api_provider.dart'; -import '../../theme/app_theme.dart'; -import '../../widgets/sojorn_app_bar.dart'; class BeaconDetailScreen extends ConsumerStatefulWidget { final Post beaconPost; @@ -26,42 +24,18 @@ class _BeaconDetailScreenState extends ConsumerState void initState() { super.initState(); _pulseController = AnimationController( - duration: const Duration(milliseconds: 1500), + duration: const Duration(milliseconds: 1200), vsync: this, ); - + final beacon = widget.beaconPost.toBeacon(); - final confidenceScore = beacon.confidenceScore; - - if (confidenceScore < 0.3) { - // Low confidence - add warning pulse - _pulseAnimation = Tween( - begin: 0.8, - end: 1.0, - ).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.easeInOut, - )); + if (beacon.isRecent) { + _pulseAnimation = Tween(begin: 0.9, end: 1.05).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); _pulseController.repeat(reverse: true); - } else if (confidenceScore > 0.7) { - // High confidence - solid, no pulse - _pulseAnimation = Tween( - begin: 1.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.linear, - )); } else { - // Medium confidence - subtle pulse - _pulseAnimation = Tween( - begin: 0.9, - end: 1.0, - ).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.easeInOut, - )); - _pulseController.repeat(reverse: true); + _pulseAnimation = const AlwaysStoppedAnimation(1.0); } } @@ -74,55 +48,29 @@ class _BeaconDetailScreenState extends ConsumerState Beacon get _beacon => widget.beaconPost.toBeacon(); Post get _post => widget.beaconPost; - Color get _statusColor { - switch (_beacon.status) { - case BeaconStatus.green: - return Colors.green; - case BeaconStatus.yellow: - return Colors.orange; - case BeaconStatus.red: - return Colors.red; - } - } - - String get _statusLabel { - switch (_beacon.status) { - case BeaconStatus.green: - return 'Verified'; - case BeaconStatus.yellow: - return 'Caution'; - case BeaconStatus.red: - return 'Unverified'; - } - } - @override Widget build(BuildContext context) { + final severityColor = _beacon.pinColor; + return Scaffold( + backgroundColor: const Color(0xFF0F0F23), body: CustomScrollView( slivers: [ - // Immersive Header with Image - _buildImmersiveHeader(), - - // Content + _buildHeader(severityColor), SliverToBoxAdapter( child: Container( - decoration: BoxDecoration( - color: AppTheme.scaffoldBg, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), - ), + decoration: const BoxDecoration( + color: Color(0xFF1A1A2E), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildStatusSection(), - _buildBeaconInfo(), - _buildAuthorInfo(), - const SizedBox(height: 32), - _buildActionButtons(), + _buildIncidentInfo(severityColor), + _buildMetaRow(), + _buildVerificationSection(severityColor), const SizedBox(height: 24), - _buildConfidenceIndicator(), + _buildActionButtons(severityColor), const SizedBox(height: 40), ], ), @@ -133,19 +81,16 @@ class _BeaconDetailScreenState extends ConsumerState ); } - Widget _buildImmersiveHeader() { + Widget _buildHeader(Color severityColor) { final hasImage = _post.imageUrl != null && _post.imageUrl!.isNotEmpty; - + return SliverAppBar( - expandedHeight: hasImage ? 300 : 120, + expandedHeight: hasImage ? 280 : 140, pinned: true, - backgroundColor: Colors.transparent, + backgroundColor: const Color(0xFF0F0F23), leading: Container( margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(12), - ), + decoration: BoxDecoration(color: Colors.black38, borderRadius: BorderRadius.circular(12)), child: IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.arrow_back, color: Colors.white), @@ -156,90 +101,43 @@ class _BeaconDetailScreenState extends ConsumerState fit: StackFit.expand, children: [ if (hasImage) - Hero( - tag: 'beacon-image-${_post.id}', - child: Image.network( - _post.imageUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildFallbackImage(); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - color: AppTheme.navyBlue, - child: Center( - child: CircularProgressIndicator( - color: Colors.white, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ), - ); - }, - ), - ) + Image.network(_post.imageUrl!, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildFallbackHeader(severityColor)) else - _buildFallbackImage(), - - // Gradient overlay for text readability + _buildFallbackHeader(severityColor), + + // Dark gradient overlay Container( decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - Colors.black.withValues(alpha: 0.3), - ], + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Colors.black.withValues(alpha: 0.6), Colors.transparent, Colors.black.withValues(alpha: 0.5)], ), ), ), - - // Status indicator with pulse + + // Severity + incident status badges Positioned( - top: 60, - right: 16, + top: 60, right: 16, child: AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: _statusColor.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: _statusColor.withValues(alpha: 0.3), - blurRadius: 8, - spreadRadius: 2, - ), - ], + color: severityColor.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: severityColor.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - _beacon.status == BeaconStatus.green - ? Icons.verified - : Icons.warning, - color: Colors.white, - size: 16, - ), + Icon(_beacon.severity.icon, color: Colors.white, size: 14), const SizedBox(width: 4), - Text( - _statusLabel, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), + Text(_beacon.severity.label, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11)), ], ), ), @@ -247,24 +145,42 @@ class _BeaconDetailScreenState extends ConsumerState }, ), ), + + // LIVE badge + if (_beacon.isRecent) + Positioned( + top: 60, left: 60, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red, borderRadius: BorderRadius.circular(4), + ), + child: const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ), + ), ], ), ), ); } - Widget _buildFallbackImage() { + Widget _buildFallbackHeader(Color severityColor) { return Container( - color: AppTheme.navyBlue, - child: Icon( - _beacon.beaconType.icon, - color: Colors.white, - size: 80, + color: const Color(0xFF0F0F23), + child: Center( + child: Container( + width: 80, height: 80, + decoration: BoxDecoration( + color: severityColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon(_beacon.beaconType.icon, color: severityColor, size: 40), + ), ), ); } - Widget _buildStatusSection() { + Widget _buildIncidentInfo(Color severityColor) { return Padding( padding: const EdgeInsets.all(20), child: Column( @@ -273,177 +189,184 @@ class _BeaconDetailScreenState extends ConsumerState Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: _beacon.beaconType.color.withValues(alpha: 0.1), + color: severityColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), - child: Icon( - _beacon.beaconType.icon, - color: _beacon.beaconType.color, - size: 24, - ), + child: Icon(_beacon.beaconType.icon, color: severityColor, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _beacon.beaconType.displayName, - style: AppTheme.headlineSmall, - ), - Text( - '${_beacon.getFormattedDistance()} away', - style: AppTheme.bodyMedium?.copyWith( - color: AppTheme.textDisabled, - ), - ), + Text(_beacon.beaconType.displayName, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Text('${_beacon.getFormattedDistance()} away', + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 13)), ], ), ), + // Incident status chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _beacon.incidentStatus == BeaconIncidentStatus.active + ? Colors.green.withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _beacon.incidentStatus == BeaconIncidentStatus.active + ? Colors.green.withValues(alpha: 0.5) + : Colors.grey.withValues(alpha: 0.3), + ), + ), + child: Text(_beacon.incidentStatus.label, + style: TextStyle( + color: _beacon.incidentStatus == BeaconIncidentStatus.active ? Colors.green : Colors.grey, + fontSize: 11, fontWeight: FontWeight.w600)), + ), ], ), const SizedBox(height: 16), - Text( - _post.body, - style: AppTheme.postBody, - ), + Text(_post.body, + style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 15, height: 1.5)), ], ), ); } - Widget _buildBeaconInfo() { + Widget _buildMetaRow() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - Icon( - Icons.access_time, - color: AppTheme.textDisabled, - size: 16, - ), - const SizedBox(width: 4), - Text( - _beacon.getTimeAgo(), - style: AppTheme.bodyMedium?.copyWith( - color: AppTheme.textDisabled, - ), - ), - const Spacer(), - if (_post.latitude != null && _post.longitude != null) ...[ - Icon( - Icons.location_on, - color: AppTheme.textDisabled, - size: 16, - ), - const SizedBox(width: 4), - Text( - 'Location tagged', - style: AppTheme.bodyMedium?.copyWith( - color: AppTheme.textDisabled, - ), - ), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _metaItem(Icons.schedule, _beacon.getTimeAgo()), + _metaItem(Icons.location_on, _beacon.getFormattedDistance()), + _metaItem(Icons.visibility, '${_beacon.verificationCount} verified'), + _metaItem(Icons.radar, '${_beacon.radius}m radius'), ], - ], + ), ), ); } - Widget _buildAuthorInfo() { - // Beacons are anonymous — no author info displayed - return const SizedBox.shrink(); - } - - Widget _buildActionButtons() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - // Vouch Button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isVouching ? null : () => _vouchBeacon(), - icon: _isVouching - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.thumb_up), - label: Text(_isVouching ? 'Confirming...' : 'Vouch for this Beacon'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(height: 12), - // Report Button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isReporting ? null : () => _reportBeacon(), - icon: _isReporting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.red, - ), - ) - : const Icon(Icons.thumb_down), - label: Text(_isReporting ? 'Reporting...' : 'Report Issue'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - ), + Widget _metaItem(IconData icon, String label) { + return Column( + children: [ + Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(height: 4), + Text(label, style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 10)), + ], ); } - Widget _buildConfidenceIndicator() { + Widget _buildVerificationSection(Color severityColor) { + final verCount = _beacon.verificationCount; + final isVerified = verCount >= 3; + return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Community Confidence', - style: AppTheme.labelMedium, - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: LinearProgressIndicator( - value: _beacon.confidenceScore, - minHeight: 8, - backgroundColor: Colors.grey.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation(_statusColor), + Text('Community Verification', + style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isVerified ? Colors.green.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + Icon(isVerified ? Icons.verified : Icons.pending, + color: isVerified ? Colors.green : Colors.amber, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(isVerified ? 'Verified by community' : 'Awaiting verification', + style: TextStyle( + color: isVerified ? Colors.green : Colors.amber, + fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text('$verCount / 3 neighbors confirmed this report', + style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 12)), + ], + ), + ), + ], ), ), - const SizedBox(height: 4), - Text( - 'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%', - style: AppTheme.labelSmall?.copyWith( - color: AppTheme.textDisabled, + const SizedBox(height: 10), + // Verification progress bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (verCount / 3).clamp(0.0, 1.0), + minHeight: 4, + backgroundColor: Colors.white.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(isVerified ? Colors.green : Colors.amber), + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(Color severityColor) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // "I see this too" button — primary action + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton.icon( + onPressed: _isVouching ? null : _vouchBeacon, + icon: _isVouching + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.visibility, size: 20), + label: Text(_isVouching ? 'Confirming...' : 'I see this too', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade700, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + disabledBackgroundColor: Colors.green.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(height: 10), + // False alarm / report button — secondary action + SizedBox( + width: double.infinity, + height: 44, + child: OutlinedButton.icon( + onPressed: _isReporting ? null : _reportBeacon, + icon: _isReporting + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red)) + : Icon(Icons.flag, size: 18, color: Colors.red.withValues(alpha: 0.7)), + label: Text(_isReporting ? 'Reporting...' : 'False alarm / Report', + style: TextStyle(color: Colors.red.withValues(alpha: 0.7), fontSize: 13)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Colors.red.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), ), ), ], @@ -454,25 +377,19 @@ class _BeaconDetailScreenState extends ConsumerState Future _vouchBeacon() async { final apiService = ref.read(apiServiceProvider); setState(() => _isVouching = true); - + try { await apiService.vouchBeacon(_post.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Thanks for confirming this beacon!'), - backgroundColor: Colors.green, - ), + const SnackBar(content: Text('Thanks for confirming this report!'), backgroundColor: Colors.green), ); Navigator.of(context).pop(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Something went wrong: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Something went wrong: $e'), backgroundColor: Colors.red), ); } } finally { @@ -483,25 +400,19 @@ class _BeaconDetailScreenState extends ConsumerState Future _reportBeacon() async { final apiService = ref.read(apiServiceProvider); setState(() => _isReporting = true); - + try { await apiService.reportBeacon(_post.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Report received. Thanks for keeping the community safe.'), - backgroundColor: Colors.orange, - ), + const SnackBar(content: Text('Report received. Thanks for keeping the community safe.'), backgroundColor: Colors.orange), ); Navigator.of(context).pop(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Something went wrong: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Something went wrong: $e'), backgroundColor: Colors.red), ); } } finally { diff --git a/sojorn_app/lib/screens/beacon/beacon_screen.dart b/sojorn_app/lib/screens/beacon/beacon_screen.dart index c2b5120..ad98ace 100644 --- a/sojorn_app/lib/screens/beacon/beacon_screen.dart +++ b/sojorn_app/lib/screens/beacon/beacon_screen.dart @@ -6,15 +6,8 @@ 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/local_intel.dart'; -import '../../services/local_intel_service.dart'; -import '../../theme/app_theme.dart'; -import 'package:go_router/go_router.dart'; -import 'beacon_bottom_sheet.dart'; import 'beacon_detail_screen.dart'; import 'create_beacon_sheet.dart'; -import 'widgets/intel_cards.dart'; -import 'widgets/resources_sheet.dart'; class BeaconScreen extends ConsumerStatefulWidget { final LatLng? initialMapCenter; @@ -27,7 +20,7 @@ class BeaconScreen extends ConsumerStatefulWidget { class _BeaconScreenState extends ConsumerState { final MapController _mapController = MapController(); - final LocalIntelService _intelService = LocalIntelService(); + final DraggableScrollableController _sheetController = DraggableScrollableController(); List _beacons = []; bool _isLoading = false; @@ -40,13 +33,6 @@ class _BeaconScreenState extends ConsumerState { double _currentZoom = 14.0; bool _suppressAutoCenterOnUser = false; - // Map expansion state - bool _isMapExpanded = false; - - // Local intel data - LocalIntelData _intelData = LocalIntelData(); - bool _isLoadingIntel = false; - @override void initState() { super.initState(); @@ -61,10 +47,7 @@ class _BeaconScreenState extends ConsumerState { Future _checkLocationPermission() async { final status = await Permission.location.status; if (mounted) { - setState(() { - _locationPermissionGranted = status.isGranted; - }); - + setState(() => _locationPermissionGranted = status.isGranted); if (status.isGranted) { await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser); await _loadBeacons(); @@ -76,19 +59,14 @@ class _BeaconScreenState extends ConsumerState { setState(() => _isLoadingLocation = true); try { final status = await Permission.location.request(); - setState(() { - _locationPermissionGranted = status.isGranted; - }); - + 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 beacons.')), + const SnackBar(content: Text('Location access is required to show nearby incidents.')), ); } } finally { @@ -98,12 +76,9 @@ class _BeaconScreenState extends ConsumerState { Future _getCurrentLocation({bool forceCenter = false}) async { if (!_locationPermissionGranted) return; - setState(() => _isLoadingLocation = true); try { - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); + final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); if (mounted) { setState(() { _userLocation = LatLng(position.latitude, position.longitude); @@ -112,49 +87,18 @@ class _BeaconScreenState extends ConsumerState { _suppressAutoCenterOnUser = false; } }); - - // Fetch local intel when we get location - _fetchLocalIntel(); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not get your location: $e')), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not get location: $e'))); } } finally { if (mounted) setState(() => _isLoadingLocation = false); } } - Future _fetchLocalIntel() async { - final location = _userLocation ?? _mapCenter; - - setState(() => _isLoadingIntel = true); - try { - final data = await _intelService.fetchAllIntel( - location.latitude, - location.longitude, - ); - if (mounted) { - setState(() { - _intelData = data; - _isLoadingIntel = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _intelData = LocalIntelData.withError('Failed to load intel'); - _isLoadingIntel = false; - }); - } - } - } - Future _loadBeacons({LatLng? center}) async { final target = center ?? _userLocation ?? _mapCenter; - setState(() => _isLoading = true); try { final apiService = ref.read(apiServiceProvider); @@ -163,33 +107,22 @@ class _BeaconScreenState extends ConsumerState { long: target.longitude, radius: 16000, ); - if (mounted) { - setState(() { - _beacons = beacons; - _isLoading = false; - }); - } + if (mounted) setState(() { _beacons = beacons; _isLoading = false; }); } catch (e) { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } void _onMapPositionChanged(MapCamera camera, bool hasGesture) { _mapCenter = camera.center; _currentZoom = camera.zoom; - if (hasGesture) { - _loadBeacons(center: _mapCenter); - } + if (hasGesture) _loadBeacons(center: _mapCenter); } void _onMarkerTap(Post post) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => BeaconDetailScreen(beaconPost: post), - ), - ); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BeaconDetailScreen(beaconPost: post), + )); } void _onCreateBeacon() { @@ -201,97 +134,249 @@ class _BeaconScreenState extends ConsumerState { centerLat: _mapCenter.latitude, centerLong: _mapCenter.longitude, onBeaconCreated: (post) { - setState(() { - _beacons.add(post); - }); + setState(() => _beacons.add(post)); _loadBeacons(); }, ), ); } - void _toggleMapExpanded() { - setState(() { - _isMapExpanded = !_isMapExpanded; - }); + // ─── Severity summary helpers ───────────────── + int get _criticalCount => _beacons.where((b) => b.severity == 'critical').length; + int get _highCount => _beacons.where((b) => b.severity == 'high').length; + int get _mediumCount => _beacons.where((b) => b.severity == 'medium').length; + + String get _safetyStatusText { + final total = _beacons.length; + if (total == 0) return 'No incidents nearby'; + final critical = _criticalCount; + final high = _highCount; + if (critical > 0) return '$critical critical incident${critical > 1 ? 's' : ''} nearby'; + if (high > 0) return '$high danger alert${high > 1 ? 's' : ''} nearby'; + return '$total incident${total > 1 ? 's' : ''} nearby'; } - void _showResourcesSheet() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => ResourcesSheet( - resources: _intelData.resources, - userLocation: _userLocation, - ), - ); + Color get _safetyStatusColor { + if (_criticalCount > 0) return Colors.red; + if (_highCount > 0) return Colors.deepOrange; + if (_mediumCount > 0) return Colors.amber; + return Colors.green; } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final accentColor = theme.primaryColor; - - // Show overlays for permissions/opt-in if (!_locationPermissionGranted) { - return Scaffold( - body: _buildLocationPermissionOverlay(context, accentColor), - ); + return Scaffold(body: _buildLocationPermissionOverlay(context)); } - // Full-screen map mode - if (_isMapExpanded) { - return Scaffold( - body: Stack( - children: [ - _buildMap(), - _buildTopBar(context, showCollapseButton: true), - _buildBeaconFab(accentColor), - ], - ), - ); - } - - // Split-panel dashboard mode return Scaffold( - body: Column( + body: Stack( children: [ - // Top 40%: Map - Expanded( - flex: 4, - child: Stack( - children: [ - _buildMap(), - _buildTopBar(context, showExpandButton: true), - _buildCenterMarker(theme), - ], - ), + // Full-screen dark map + _buildMap(), + + // Top bar overlay + _buildTopBar(context), + + // Draggable bottom sheet — the "Radar" overlay + DraggableScrollableSheet( + controller: _sheetController, + initialChildSize: 0.12, + minChildSize: 0.12, + maxChildSize: 0.75, + snap: true, + snapSizes: const [0.12, 0.45, 0.75], + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A2E), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: CustomScrollView( + controller: scrollController, + slivers: [ + // Handle + safety status (collapsed view) + SliverToBoxAdapter(child: _buildSheetHeader()), + + // Incident list (expanded view) + if (_beacons.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon(Icons.shield, color: Colors.green.withValues(alpha: 0.5), size: 48), + const SizedBox(height: 12), + Text('All clear in your area', + style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 14)), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildIncidentCard(_beacons[index]), + childCount: _beacons.length, + ), + ), + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 40)), + ], + ), + ); + }, ), - // Bottom 60%: Intel Grid - Expanded( - flex: 6, - child: Container( - decoration: BoxDecoration( - color: AppTheme.scaffoldBg, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 16, - offset: const Offset(0, -4), - ), - ], - ), - child: _buildIntelGrid(), - ), + // Safety FAB — high contrast report button + Positioned( + bottom: 100, + right: 16, + child: _buildSafetyFab(), ), ], ), - floatingActionButton: _buildBeaconFabButton(accentColor), + ); + } + + Widget _buildSheetHeader() { + return GestureDetector( + onTap: () { + final currentSize = _sheetController.size; + if (currentSize < 0.3) { + _sheetController.animateTo(0.45, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } else { + _sheetController.animateTo(0.12, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + }, + child: Container( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Column( + children: [ + // Drag handle + Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + // Safety status row + Row( + children: [ + Container( + width: 10, height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _safetyStatusColor, + boxShadow: [BoxShadow(color: _safetyStatusColor.withValues(alpha: 0.5), blurRadius: 6)], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text(_safetyStatusText, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), + ), + if (_isLoading) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)), + const SizedBox(width: 8), + Text('${_beacons.length}', style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 13)), + Icon(Icons.keyboard_arrow_up, color: Colors.white.withValues(alpha: 0.4), size: 20), + ], + ), + ], + ), + ), + ); + } + + 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: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isRecent ? severityColor.withValues(alpha: 0.6) : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + // Severity indicator + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: severityColor.withValues(alpha: 0.2), + 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: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis), + ), + if (isRecent) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text('LIVE', style: TextStyle(color: Colors.red, fontSize: 9, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 3), + Text(beacon.body, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 12)), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.schedule, size: 11, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text(beacon.getTimeAgo(), style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + const SizedBox(width: 10), + Icon(Icons.location_on, size: 11, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text(beacon.getFormattedDistance(), style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + const Spacer(), + Icon(Icons.visibility, size: 11, color: Colors.white.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text('${beacon.verificationCount}', style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 11)), + ], + ), + ], + ), + ), + ], + ), + ), ); } @@ -306,9 +391,9 @@ class _BeaconScreenState extends ConsumerState { maxZoom: 19.0, ), children: [ + // Dark mode tiles for tactical feel TileLayer( - urlTemplate: - 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + urlTemplate: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', subdomains: const ['a', 'b', 'c', 'd'], userAgentPackageName: 'com.sojorn.app', retinaMode: RetinaMode.isHighDensity(context), @@ -324,51 +409,9 @@ class _BeaconScreenState extends ConsumerState { ); } - Widget _buildCenterMarker(ThemeData theme) { - if (!_locationPermissionGranted) { - return const SizedBox.shrink(); - } - - return Center( - child: IgnorePointer( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: theme.primaryColor.withValues(alpha: 0.15), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon( - Icons.place_rounded, - color: theme.primaryColor, - size: 32, - ), - ), - ], - ), - ), - ); - } - - Widget _buildTopBar( - BuildContext context, { - bool showExpandButton = false, - bool showCollapseButton = false, - }) { + Widget _buildTopBar(BuildContext context) { return Positioned( - top: 0, - left: 0, - right: 0, + top: 0, left: 0, right: 0, child: SafeArea( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -376,10 +419,7 @@ class _BeaconScreenState extends ConsumerState { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - ], + colors: [Colors.black.withValues(alpha: 0.8), Colors.transparent], ), ), child: Row( @@ -387,81 +427,34 @@ class _BeaconScreenState extends ConsumerState { IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.arrow_back, color: Colors.white), - style: IconButton.styleFrom( - backgroundColor: Colors.black26, - ), + style: IconButton.styleFrom(backgroundColor: Colors.black26), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Beacon Network', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - '${_beacons.length} beacons nearby', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white70, - ), - ), + Text('Beacon Network', + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.bold)), + Text('${_beacons.length} incident${_beacons.length != 1 ? 's' : ''} nearby', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70)), ], ), ), if (_isLoading || _isLoadingLocation) const Padding( padding: EdgeInsets.only(right: 8), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ), - if (showExpandButton) - IconButton( - onPressed: _toggleMapExpanded, - icon: const Icon(Icons.fullscreen, color: Colors.white), - tooltip: 'Expand Map', - style: IconButton.styleFrom( - backgroundColor: Colors.black26, - ), - ), - if (showCollapseButton) - IconButton( - onPressed: _toggleMapExpanded, - icon: const Icon(Icons.fullscreen_exit, color: Colors.white), - tooltip: 'Show Dashboard', - style: IconButton.styleFrom( - backgroundColor: Colors.black26, - ), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))), ), IconButton( - onPressed: _isLoadingLocation - ? null - : () => _getCurrentLocation(forceCenter: true), + onPressed: _isLoadingLocation ? null : () => _getCurrentLocation(forceCenter: true), icon: const Icon(Icons.my_location, color: Colors.white), - tooltip: 'My Location', - style: IconButton.styleFrom( - backgroundColor: Colors.black26, - ), + style: IconButton.styleFrom(backgroundColor: Colors.black26), ), IconButton( - onPressed: () { - _loadBeacons(); - _fetchLocalIntel(); - }, + onPressed: () => _loadBeacons(), icon: const Icon(Icons.refresh, color: Colors.white), - tooltip: 'Refresh', - style: IconButton.styleFrom( - backgroundColor: Colors.black26, - ), + style: IconButton.styleFrom(backgroundColor: Colors.black26), ), ], ), @@ -470,218 +463,48 @@ class _BeaconScreenState extends ConsumerState { ); } - Widget _buildIntelGrid() { - return CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - // Header - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), - child: Row( - children: [ - Icon(Icons.insights, color: AppTheme.navyBlue, size: 24), - const SizedBox(width: 8), - Text( - 'Local Intel', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.navyBlue, - ), - ), - const Spacer(), - if (_isLoadingIntel) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - ), - ), - - // Intel Cards Grid - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.1, - ), - delegate: SliverChildListDelegate([ - // Card 1: Conditions - ConditionsCard( - weather: _intelData.weather, - isLoading: _isLoadingIntel, - ), - - // Card 2: Hazards - HazardsCard( - hazards: _intelData.hazards, - isLoading: _isLoadingIntel, - ), - - // Card 3: Visibility - VisibilityCard( - visibility: _intelData.visibility, - isLoading: _isLoadingIntel, - ), - - // Card 4: Public Resources - ResourcesCard( - resourceCount: _intelData.resources.length, - isLoading: _isLoadingIntel, - onTap: _showResourcesSheet, - ), - ]), - ), - ), - - // Secure Chat Entry Point - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: _buildSecureChatCard(), - ), - ), - - // Bottom padding - const SliverToBoxAdapter( - child: SizedBox(height: 80), - ), - ], - ); - } - - Widget _buildSecureChatCard() { - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppTheme.egyptianBlue, width: 1), - ), - child: InkWell( - onTap: () { - context.push('/secure-chat'); - }, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.ksuPurple.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.lock_outline, - color: AppTheme.ksuPurple, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Secure Messages', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.navyBlue, - ), - ), - const SizedBox(height: 4), - Text( - 'End-to-end encrypted · Mutual follows only', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppTheme.textDisabled, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios, - color: AppTheme.egyptianBlue, - size: 16, - ), - ], - ), - ), - ), - ); - } - - Widget _buildBeaconFab(Color accentColor) { - return Positioned( - bottom: 24, - right: 24, - child: _buildBeaconFabButton(accentColor), - ); - } - - Widget _buildBeaconFabButton(Color accentColor) { - return FloatingActionButton.extended( + Widget _buildSafetyFab() { + return FloatingActionButton( + heroTag: 'safety_report', onPressed: _onCreateBeacon, - label: const Text('Drop Beacon'), - icon: const Icon(Icons.add_location), - backgroundColor: accentColor, + backgroundColor: Colors.red, foregroundColor: Colors.white, + elevation: 8, + child: const Icon(Icons.warning_rounded, size: 28), ); } - Widget _buildLocationPermissionOverlay( - BuildContext context, Color accentColor) { + Widget _buildLocationPermissionOverlay(BuildContext context) { return Container( - color: Colors.black87, + color: const Color(0xFF0F0F23), child: Center( child: Container( margin: const EdgeInsets.all(32), padding: const EdgeInsets.all(32), decoration: BoxDecoration( - color: Theme.of(context).cardColor, + color: const Color(0xFF1A1A2E), borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.location_on, size: 64, color: accentColor), + const Icon(Icons.shield, size: 64, color: Colors.red), const SizedBox(height: 24), - Text( - 'Allow location access', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + Text('Enable Location', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: Colors.white), + textAlign: TextAlign.center), const SizedBox(height: 16), - Text( - 'We use your location to show nearby beacons and local intel.', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + Text('Location access is required to show safety alerts in your area.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white70), + textAlign: TextAlign.center), const SizedBox(height: 24), ElevatedButton.icon( - onPressed: - _isLoadingLocation ? null : _requestLocationPermission, + onPressed: _isLoadingLocation ? null : _requestLocationPermission, icon: _isLoadingLocation - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_on), label: const Text('Allow Location'), - style: ElevatedButton.styleFrom( - backgroundColor: accentColor, - foregroundColor: Colors.white, - ), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white), ), ], ), @@ -691,12 +514,12 @@ class _BeaconScreenState extends ConsumerState { } Marker _createMarker(Post post) { - final typeColor = post.beaconType?.color ?? Colors.blue; - final typeIcon = post.beaconType?.icon ?? Icons.location_on; - final confidenceScore = post.confidenceScore ?? 0.5; + 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( @@ -710,10 +533,10 @@ class _BeaconScreenState extends ConsumerState { height: 48, child: GestureDetector( onTap: () => _onMarkerTap(post), - child: _BeaconMarker( - color: typeColor, + child: _SeverityMarker( + color: severityColor, icon: typeIcon, - confidenceScore: confidenceScore, + isRecent: isRecent, ), ), ); @@ -729,22 +552,23 @@ class _BeaconScreenState extends ConsumerState { } } -class _BeaconMarker extends StatefulWidget { +// ─── Severity-colored Marker with pulse for recent ───────────── +class _SeverityMarker extends StatefulWidget { final Color color; final IconData icon; - final double confidenceScore; + final bool isRecent; - const _BeaconMarker({ + const _SeverityMarker({ required this.color, required this.icon, - required this.confidenceScore, + required this.isRecent, }); @override - State<_BeaconMarker> createState() => _BeaconMarkerState(); + State<_SeverityMarker> createState() => _SeverityMarkerState(); } -class _BeaconMarkerState extends State<_BeaconMarker> +class _SeverityMarkerState extends State<_SeverityMarker> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _pulseAnimation; @@ -753,27 +577,17 @@ class _BeaconMarkerState extends State<_BeaconMarker> void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 1500), + duration: const Duration(milliseconds: 1200), vsync: this, ); - if (widget.confidenceScore < 0.3) { - // Low confidence - warning pulse - _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( + if (widget.isRecent) { + _pulseAnimation = Tween(begin: 0.85, end: 1.15).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); _controller.repeat(reverse: true); - } else if (widget.confidenceScore > 0.7) { - // High confidence - solid, no pulse - _pulseAnimation = Tween(begin: 1.0, end: 1.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.linear), - ); } else { - // Medium confidence - subtle pulse - _pulseAnimation = Tween(begin: 0.9, end: 1.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - _controller.repeat(reverse: true); + _pulseAnimation = const AlwaysStoppedAnimation(1.0); } } @@ -796,13 +610,13 @@ class _BeaconMarkerState extends State<_BeaconMarker> color: widget.color, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - spreadRadius: widget.confidenceScore < 0.3 ? 2 : 0, + 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: Colors.white, size: 28), + child: Icon(widget.icon, color: Colors.white, size: 26), ), ); }, @@ -810,10 +624,10 @@ class _BeaconMarkerState extends State<_BeaconMarker> } } +// ─── Pulsing user location dot ───────────── class _PulsingLocationIndicator extends StatefulWidget { @override - State<_PulsingLocationIndicator> createState() => - _PulsingLocationIndicatorState(); + State<_PulsingLocationIndicator> createState() => _PulsingLocationIndicatorState(); } class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator> @@ -828,7 +642,6 @@ class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator> 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), ); @@ -849,36 +662,20 @@ class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator> alignment: Alignment.center, children: [ Container( - width: 40, - height: 40, + width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, - color: - Colors.blue.withValues(alpha: 0.3 * (1 - _animation.value)), - border: Border.all( - color: Colors.blue - .withValues(alpha: 0.5 * (1 - _animation.value)), - width: 2, - ), + color: Colors.blue.withValues(alpha: 0.3 * (1 - _animation.value)), + border: Border.all(color: Colors.blue.withValues(alpha: 0.5 * (1 - _animation.value)), width: 2), ), ), Container( - width: 16, - height: 16, + width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue, - border: Border.all( - color: Colors.white, - width: 3, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - spreadRadius: 1, - ), - ], + border: Border.all(color: Colors.white, width: 3), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, spreadRadius: 1)], ), ), ], diff --git a/sojorn_app/lib/screens/beacon/create_beacon_sheet.dart b/sojorn_app/lib/screens/beacon/create_beacon_sheet.dart index f15cac1..9aa9ca1 100644 --- a/sojorn_app/lib/screens/beacon/create_beacon_sheet.dart +++ b/sojorn_app/lib/screens/beacon/create_beacon_sheet.dart @@ -25,27 +25,28 @@ class CreateBeaconSheet extends ConsumerStatefulWidget { class _CreateBeaconSheetState extends ConsumerState { final ImageUploadService _imageUploadService = ImageUploadService(); - final _titleController = TextEditingController(); final _descriptionController = TextEditingController(); BeaconType _selectedType = BeaconType.safety; + BeaconSeverity _selectedSeverity = BeaconSeverity.medium; bool _isSubmitting = false; bool _isUploadingImage = false; File? _selectedImage; String? _uploadedImageUrl; final List _types = [ + BeaconType.suspiciousActivity, + BeaconType.hazard, + BeaconType.fire, BeaconType.police, + BeaconType.safety, BeaconType.checkpoint, BeaconType.taskForce, - BeaconType.hazard, - BeaconType.safety, BeaconType.community, ]; @override void dispose() { - _titleController.dispose(); _descriptionController.dispose(); super.dispose(); } @@ -96,12 +97,11 @@ class _CreateBeaconSheetState extends ConsumerState { } Future _submit() async { - final title = _titleController.text.trim(); final description = _descriptionController.text.trim(); - if (title.isEmpty || description.isEmpty) { + if (description.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please add a title and details.')), + const SnackBar(content: Text('Please describe what you see.')), ); return; } @@ -112,7 +112,6 @@ class _CreateBeaconSheetState extends ConsumerState { final apiService = ref.read(apiServiceProvider); final body = _buildBeaconBody( - title: title, description: description, lat: widget.centerLat, long: widget.centerLong, @@ -129,6 +128,7 @@ class _CreateBeaconSheetState extends ConsumerState { beaconType: _selectedType, lat: widget.centerLat, long: widget.centerLong, + severity: _selectedSeverity.value, ); if (mounted) { @@ -138,7 +138,7 @@ class _CreateBeaconSheetState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not create the beacon: $e')), + SnackBar(content: Text('Could not create the report: $e')), ); } } finally { @@ -147,203 +147,242 @@ class _CreateBeaconSheetState extends ConsumerState { } String _buildBeaconBody({ - required String title, required String description, required double lat, required double long, required BeaconType type, }) { - final locationLink = - 'sojorn://beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}'; - return [ - 'Beacon: ${type.displayName}', - 'Title: $title', - 'Details: $description', - 'Location: ${lat.toStringAsFixed(4)}, ${long.toStringAsFixed(4)}', - 'Open in map: $locationLink', - ].join('\n'); + return description; } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final accentColor = theme.primaryColor; - return PopScope( onPopInvoked: (didPop) { if (_isSubmitting) return; }, child: Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + decoration: const BoxDecoration( + color: Color(0xFF1A1A2E), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 20, left: 20, right: 20, - top: 20, + top: 16, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Drag handle Center( child: Container( - width: 40, - height: 4, + width: 40, height: 4, decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.3), + color: Colors.white.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), ), - const SizedBox(height: 20), - Text( - 'Create a Beacon', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Share an alert near ${widget.centerLat.toStringAsFixed(4)}, ${widget.centerLong.toStringAsFixed(4)}', - style: theme.textTheme.bodySmall?.copyWith( - color: - theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 20), - Text( - 'Category', - style: theme.textTheme.labelLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _types.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final type = _types[index]; - final isSelected = type == _selectedType; - return ChoiceChip( - label: Text(type.displayName), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setState(() => _selectedType = type); - } - }, - selectedColor: accentColor, - labelStyle: TextStyle( - color: isSelected - ? Colors.white - : theme.textTheme.bodyMedium?.color, - ), - ); - }, - ), - ), - if (_selectedType.helperText != null) ...[ - const SizedBox(height: 8), - Text( - _selectedType.helperText!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color - ?.withValues(alpha: 0.6), - fontStyle: FontStyle.italic, + const SizedBox(height: 16), + + // Header + Row( + children: [ + const Icon(Icons.warning_rounded, color: Colors.red, size: 24), + const SizedBox(width: 8), + const Expanded( + child: Text('Report Incident', + style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), ), - ), - ], - const SizedBox(height: 16), - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Title', - hintText: 'Short headline for the alert', - border: OutlineInputBorder(), - ), - maxLength: 100, + IconButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + icon: Icon(Icons.close, color: Colors.white.withValues(alpha: 0.6)), + ), + ], ), - const SizedBox(height: 16), + const SizedBox(height: 4), + Text('Near ${widget.centerLat.toStringAsFixed(4)}, ${widget.centerLong.toStringAsFixed(4)}', + style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 12)), + const SizedBox(height: 20), + + // Incident type — grid of preset buttons + Text('What\'s happening?', + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: _types.map((type) { + final isSelected = type == _selectedType; + return GestureDetector( + onTap: () => setState(() => _selectedType = type), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? type.color.withValues(alpha: 0.25) : const Color(0xFF16213E), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? type.color : Colors.white.withValues(alpha: 0.1), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(type.icon, size: 16, color: isSelected ? type.color : Colors.white.withValues(alpha: 0.6)), + const SizedBox(width: 6), + Text(type.displayName, + style: TextStyle( + color: isSelected ? type.color : Colors.white.withValues(alpha: 0.7), + fontSize: 12, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal)), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + + // Severity selector + Text('Severity', + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Row( + children: BeaconSeverity.values.map((sev) { + final isSelected = sev == _selectedSeverity; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedSeverity = sev), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? sev.color.withValues(alpha: 0.25) : const Color(0xFF16213E), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.1), + ), + ), + child: Column( + children: [ + Icon(sev.icon, size: 18, color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.5)), + const SizedBox(height: 4), + Text(sev.label, + style: TextStyle( + color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.5), + fontSize: 10, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + + // Description TextFormField( controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description', - hintText: 'Add context that helps people nearby', - border: OutlineInputBorder(), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: 'Describe what you see...', + hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.3)), + filled: true, + fillColor: const Color(0xFF16213E), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red, width: 1), + ), + counterStyle: TextStyle(color: Colors.white.withValues(alpha: 0.3)), ), - maxLines: 4, + maxLines: 3, maxLength: 300, ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Photo if (_selectedImage != null) ...[ Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.file( - _selectedImage!, - height: 150, - width: double.infinity, - fit: BoxFit.cover, - ), + child: Image.file(_selectedImage!, height: 120, width: double.infinity, fit: BoxFit.cover), ), Positioned( - top: 8, - right: 8, + top: 6, right: 6, child: IconButton( onPressed: _removeImage, - icon: const Icon(Icons.close, color: Colors.white), - style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.6), - ), + icon: const Icon(Icons.close, color: Colors.white, size: 18), + style: IconButton.styleFrom(backgroundColor: Colors.black54, padding: const EdgeInsets.all(4)), ), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), ] else ...[ - OutlinedButton.icon( - onPressed: _isUploadingImage ? null : _pickImage, - icon: _isUploadingImage - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.add_photo_alternate), - label: Text(_isUploadingImage - ? 'Uploading...' - : 'Add photo (optional)'), + GestureDetector( + onTap: _isUploadingImage ? null : _pickImage, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF16213E), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isUploadingImage) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)) + else + Icon(Icons.add_photo_alternate, size: 18, color: Colors.white.withValues(alpha: 0.5)), + const SizedBox(width: 8), + Text(_isUploadingImage ? 'Uploading...' : 'Add photo evidence', + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 13)), + ], + ), + ), ), const SizedBox(height: 16), ], + + // Submit button SizedBox( width: double.infinity, + height: 48, child: ElevatedButton( onPressed: _isSubmitting ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + disabledBackgroundColor: Colors.red.withValues(alpha: 0.3), + ), child: _isSubmitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create Beacon'), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: TextButton( - onPressed: - _isSubmitting ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.warning_rounded, size: 18), + SizedBox(width: 8), + Text('Submit Report', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + ], + ), ), ), + const SizedBox(height: 8), ], ), ), diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 8a999a7..49e0d71 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -554,6 +554,7 @@ class ApiService { BeaconType? beaconType, double? lat, double? long, + String? severity, bool userWarned = false, bool isNsfw = false, String? nsfwReason, @@ -607,6 +608,7 @@ class ApiService { if (beaconType != null) 'beacon_type': beaconType.value, if (lat != null) 'beacon_lat': lat, if (long != null) 'beacon_long': long, + if (severity != null) 'severity': severity, if (userWarned) 'user_warned': true, if (isNsfw) 'is_nsfw': true, if (nsfwReason != null) 'nsfw_reason': nsfwReason,