feat: beacon tactical dashboard - dark map-first UI, DraggableScrollableSheet, severity pins, 'I see this too' verification, incident categories

This commit is contained in:
Patrick Britton 2026-02-09 10:43:34 -06:00
parent 442b4bef32
commit 5780f0ff75
6 changed files with 845 additions and 1073 deletions

View file

@ -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,
);
}
}

View file

@ -19,130 +19,158 @@ class _BeaconBottomSheetState extends ConsumerState<BeaconBottomSheet> {
@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),
// Handle
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.2),
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
// 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: statusColor),
border: Border.all(color: severityColor.withValues(alpha: 0.5)),
),
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,
),
),
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(height: 16),
// Beacon type
Text(
post.beaconType?.displayName ?? 'Community',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7),
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(
flex: 3,
child: SizedBox(
height: 42,
child: ElevatedButton.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),
? 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(
flex: 2,
child: SizedBox(
height: 42,
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'),
? 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(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
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<Color>(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<BeaconBottomSheet> {
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<BeaconBottomSheet> {
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<BeaconBottomSheet> {
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';
}
}
}

View file

@ -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<BeaconDetailScreen>
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<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
if (beacon.isRecent) {
_pulseAnimation = Tween<double>(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<double>(
begin: 1.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.linear,
));
} else {
// Medium confidence - subtle pulse
_pulseAnimation = Tween<double>(
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<BeaconDetailScreen>
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<BeaconDetailScreen>
);
}
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<BeaconDetailScreen>
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(),
_buildFallbackHeader(severityColor),
// Gradient overlay for text readability
// 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<BeaconDetailScreen>
},
),
),
// 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<BeaconDetailScreen>
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: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF16213E),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
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,
),
),
],
_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(
Widget _metaItem(IconData icon, String label) {
return 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),
),
),
),
),
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,
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)),
),
const SizedBox(height: 8),
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: 10),
// Verification progress bar
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _beacon.confidenceScore,
minHeight: 8,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
valueColor: AlwaysStoppedAnimation<Color>(_statusColor),
value: (verCount / 3).clamp(0.0, 1.0),
minHeight: 4,
backgroundColor: Colors.white.withValues(alpha: 0.1),
valueColor: AlwaysStoppedAnimation<Color>(isVerified ? Colors.green : Colors.amber),
),
),
const SizedBox(height: 4),
Text(
'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%',
style: AppTheme.labelSmall?.copyWith(
color: AppTheme.textDisabled,
],
),
);
}
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)),
),
),
),
],
@ -459,20 +382,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
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 {
@ -488,20 +405,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
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 {

File diff suppressed because it is too large Load diff

View file

@ -25,27 +25,28 @@ class CreateBeaconSheet extends ConsumerStatefulWidget {
class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
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<BeaconType> _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<CreateBeaconSheet> {
}
Future<void> _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<CreateBeaconSheet> {
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<CreateBeaconSheet> {
beaconType: _selectedType,
lat: widget.centerLat,
long: widget.centerLong,
severity: _selectedSeverity.value,
);
if (mounted) {
@ -138,7 +138,7 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
} 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<CreateBeaconSheet> {
}
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)),
),
IconButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: Colors.white.withValues(alpha: 0.6)),
),
],
const SizedBox(height: 16),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
hintText: 'Short headline for the alert',
border: OutlineInputBorder(),
),
maxLength: 100,
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,
),
const SizedBox(height: 16),
),
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)),
),
maxLines: 4,
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: 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),
] 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)'),
),
const SizedBox(height: 16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Beacon'),
),
),
const SizedBox(height: 12),
] else ...[
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,
child: TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
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, 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),
],
),
),

View file

@ -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,