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 double? confidenceScore;
final bool? isActiveBeacon; final bool? isActiveBeacon;
final String? beaconStatusColor; 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? latitude;
final double? longitude; final double? longitude;
@ -140,6 +146,12 @@ class Post {
this.confidenceScore, this.confidenceScore,
this.isActiveBeacon, this.isActiveBeacon,
this.beaconStatusColor, this.beaconStatusColor,
this.severity,
this.incidentStatus,
this.radius,
this.verificationCount,
this.vouchCount,
this.reportCount,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.distanceMeters, this.distanceMeters,
@ -288,6 +300,12 @@ class Post {
confidenceScore: _parseDouble(json['confidence_score']), confidenceScore: _parseDouble(json['confidence_score']),
isActiveBeacon: json['is_active_beacon'] as bool?, isActiveBeacon: json['is_active_beacon'] as bool?,
beaconStatusColor: json['status_color'] as String?, 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), latitude: _parseLatitude(json),
longitude: _parseLongitude(json), longitude: _parseLongitude(json),
distanceMeters: _parseDouble(json['distance_meters']), distanceMeters: _parseDouble(json['distance_meters']),
@ -471,9 +489,13 @@ extension PostBeaconExtension on Post {
authorHandle: author?.handle, authorHandle: author?.handle,
authorDisplayName: author?.displayName, authorDisplayName: author?.displayName,
authorAvatarUrl: author?.avatarUrl, authorAvatarUrl: author?.avatarUrl,
vouchCount: null, vouchCount: vouchCount,
reportCount: null, reportCount: reportCount,
userVote: null, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post; final post = widget.post;
final theme = Theme.of(context); final beacon = post.toBeacon();
final accentColor = theme.primaryColor; final severityColor = beacon.pinColor;
final isRecent = beacon.isRecent;
// Determine status color from confidence score final verCount = beacon.verificationCount;
final confidenceScore = post.confidenceScore ?? 0.5; final isVerified = verCount >= 3;
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';
}
return Container( return Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: theme.cardColor, color: Color(0xFF1A1A2E),
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Status badge // Handle
Container( Center(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Container(
decoration: BoxDecoration( width: 40, height: 4,
color: statusColor.withValues(alpha: 0.2), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: Colors.white.withValues(alpha: 0.3),
border: Border.all(color: statusColor), borderRadius: BorderRadius.circular(2),
), ),
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,
),
),
],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Beacon type
Text( // Severity + LIVE badges row
post.beaconType?.displayName ?? 'Community', Row(
style: theme.textTheme.labelMedium?.copyWith( children: [
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), 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 // Body
Text( Text(post.body, maxLines: 3, overflow: TextOverflow.ellipsis,
post.body, style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 14, height: 1.4)),
style: theme.textTheme.bodyLarge, 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), const SizedBox(height: 18),
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),
// Action buttons // Action buttons
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton.icon( flex: 3,
onPressed: _isVouching ? null : () => _vouch(post.id), child: SizedBox(
icon: _isVouching height: 42,
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) child: ElevatedButton.icon(
: const Icon(Icons.thumb_up, size: 18), onPressed: _isVouching ? null : () => _vouch(post.id),
label: const Text('Vouch'), icon: _isVouching
style: OutlinedButton.styleFrom( ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
foregroundColor: Colors.green, : const Icon(Icons.visibility, size: 16),
side: const BorderSide(color: Colors.green), 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( Expanded(
child: OutlinedButton.icon( flex: 2,
onPressed: _isReporting ? null : () => _report(post.id), child: SizedBox(
icon: _isReporting height: 42,
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) child: OutlinedButton.icon(
: const Icon(Icons.thumb_down, size: 18), onPressed: _isReporting ? null : () => _report(post.id),
label: const Text('Report'), icon: _isReporting
style: OutlinedButton.styleFrom( ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red))
foregroundColor: Colors.red, : Icon(Icons.flag, size: 16, color: Colors.red.withValues(alpha: 0.7)),
side: const BorderSide(color: Colors.red), 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<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); await apiService.vouchBeacon(beaconId);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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(); Navigator.of(context).pop();
} }
@ -177,7 +205,7 @@ class _BeaconBottomSheetState extends ConsumerState<BeaconBottomSheet> {
await apiService.reportBeacon(beaconId); await apiService.reportBeacon(beaconId);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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(); Navigator.of(context).pop();
} }
@ -191,31 +219,4 @@ class _BeaconBottomSheetState extends ConsumerState<BeaconBottomSheet> {
if (mounted) setState(() => _isReporting = false); 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/post.dart';
import '../../models/beacon.dart'; import '../../models/beacon.dart';
import '../../providers/api_provider.dart'; import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/sojorn_app_bar.dart';
class BeaconDetailScreen extends ConsumerStatefulWidget { class BeaconDetailScreen extends ConsumerStatefulWidget {
final Post beaconPost; final Post beaconPost;
@ -26,42 +24,18 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
void initState() { void initState() {
super.initState(); super.initState();
_pulseController = AnimationController( _pulseController = AnimationController(
duration: const Duration(milliseconds: 1500), duration: const Duration(milliseconds: 1200),
vsync: this, vsync: this,
); );
final beacon = widget.beaconPost.toBeacon(); final beacon = widget.beaconPost.toBeacon();
final confidenceScore = beacon.confidenceScore; if (beacon.isRecent) {
_pulseAnimation = Tween<double>(begin: 0.9, end: 1.05).animate(
if (confidenceScore < 0.3) { CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
// Low confidence - add warning pulse );
_pulseAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_pulseController.repeat(reverse: true); _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 { } else {
// Medium confidence - subtle pulse _pulseAnimation = const AlwaysStoppedAnimation(1.0);
_pulseAnimation = Tween<double>(
begin: 0.9,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_pulseController.repeat(reverse: true);
} }
} }
@ -74,55 +48,29 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
Beacon get _beacon => widget.beaconPost.toBeacon(); Beacon get _beacon => widget.beaconPost.toBeacon();
Post get _post => widget.beaconPost; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final severityColor = _beacon.pinColor;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0F0F23),
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
// Immersive Header with Image _buildHeader(severityColor),
_buildImmersiveHeader(),
// Content
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: AppTheme.scaffoldBg, color: Color(0xFF1A1A2E),
borderRadius: const BorderRadius.vertical( borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
top: Radius.circular(24),
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildStatusSection(), _buildIncidentInfo(severityColor),
_buildBeaconInfo(), _buildMetaRow(),
_buildAuthorInfo(), _buildVerificationSection(severityColor),
const SizedBox(height: 32),
_buildActionButtons(),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildConfidenceIndicator(), _buildActionButtons(severityColor),
const SizedBox(height: 40), 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; final hasImage = _post.imageUrl != null && _post.imageUrl!.isNotEmpty;
return SliverAppBar( return SliverAppBar(
expandedHeight: hasImage ? 300 : 120, expandedHeight: hasImage ? 280 : 140,
pinned: true, pinned: true,
backgroundColor: Colors.transparent, backgroundColor: const Color(0xFF0F0F23),
leading: Container( leading: Container(
margin: const EdgeInsets.all(8), margin: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(color: Colors.black38, borderRadius: BorderRadius.circular(12)),
color: Colors.black26,
borderRadius: BorderRadius.circular(12),
),
child: IconButton( child: IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
@ -156,90 +101,43 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (hasImage) if (hasImage)
Hero( Image.network(_post.imageUrl!, fit: BoxFit.cover,
tag: 'beacon-image-${_post.id}', errorBuilder: (_, __, ___) => _buildFallbackHeader(severityColor))
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,
),
),
);
},
),
)
else else
_buildFallbackImage(), _buildFallbackHeader(severityColor),
// Gradient overlay for text readability // Dark gradient overlay
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter, end: Alignment.bottomCenter,
end: Alignment.bottomCenter, colors: [Colors.black.withValues(alpha: 0.6), Colors.transparent, Colors.black.withValues(alpha: 0.5)],
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
],
), ),
), ),
), ),
// Status indicator with pulse // Severity + incident status badges
Positioned( Positioned(
top: 60, top: 60, right: 16,
right: 16,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _pulseAnimation, animation: _pulseAnimation,
builder: (context, child) { builder: (context, child) {
return Transform.scale( return Transform.scale(
scale: _pulseAnimation.value, scale: _pulseAnimation.value,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _statusColor.withValues(alpha: 0.9), color: severityColor.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [BoxShadow(color: severityColor.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)],
BoxShadow(
color: _statusColor.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 2,
),
],
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(_beacon.severity.icon, color: Colors.white, size: 14),
_beacon.status == BeaconStatus.green
? Icons.verified
: Icons.warning,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(_beacon.severity.label,
_statusLabel, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11)),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
], ],
), ),
), ),
@ -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( return Container(
color: AppTheme.navyBlue, color: const Color(0xFF0F0F23),
child: Icon( child: Center(
_beacon.beaconType.icon, child: Container(
color: Colors.white, width: 80, height: 80,
size: 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( return Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@ -273,177 +189,184 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _beacon.beaconType.color.withValues(alpha: 0.1), color: severityColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(_beacon.beaconType.icon, color: severityColor, size: 22),
_beacon.beaconType.icon,
color: _beacon.beaconType.color,
size: 24,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(_beacon.beaconType.displayName,
_beacon.beaconType.displayName, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
style: AppTheme.headlineSmall, const SizedBox(height: 2),
), Text('${_beacon.getFormattedDistance()} away',
Text( style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 13)),
'${_beacon.getFormattedDistance()} away',
style: AppTheme.bodyMedium?.copyWith(
color: AppTheme.textDisabled,
),
),
], ],
), ),
), ),
// 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), const SizedBox(height: 16),
Text( Text(_post.body,
_post.body, style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 15, height: 1.5)),
style: AppTheme.postBody,
),
], ],
), ),
); );
} }
Widget _buildBeaconInfo() { Widget _buildMetaRow() {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row( child: Container(
children: [ padding: const EdgeInsets.all(12),
Icon( decoration: BoxDecoration(
Icons.access_time, color: const Color(0xFF16213E),
color: AppTheme.textDisabled, borderRadius: BorderRadius.circular(10),
size: 16, ),
), child: Row(
const SizedBox(width: 4), mainAxisAlignment: MainAxisAlignment.spaceAround,
Text( children: [
_beacon.getTimeAgo(), _metaItem(Icons.schedule, _beacon.getTimeAgo()),
style: AppTheme.bodyMedium?.copyWith( _metaItem(Icons.location_on, _beacon.getFormattedDistance()),
color: AppTheme.textDisabled, _metaItem(Icons.visibility, '${_beacon.verificationCount} verified'),
), _metaItem(Icons.radar, '${_beacon.radius}m radius'),
),
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,
),
),
], ],
], ),
), ),
); );
} }
Widget _buildAuthorInfo() { Widget _metaItem(IconData icon, String label) {
// Beacons are anonymous no author info displayed return Column(
return const SizedBox.shrink(); children: [
} Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.4)),
const SizedBox(height: 4),
Widget _buildActionButtons() { Text(label, style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 10)),
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 _buildConfidenceIndicator() { Widget _buildVerificationSection(Color severityColor) {
final verCount = _beacon.verificationCount;
final isVerified = verCount >= 3;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text('Community Verification',
'Community Confidence', style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 13, fontWeight: FontWeight.w600)),
style: AppTheme.labelMedium, const SizedBox(height: 10),
), Container(
const SizedBox(height: 8), padding: const EdgeInsets.all(14),
ClipRRect( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: const Color(0xFF16213E),
child: LinearProgressIndicator( borderRadius: BorderRadius.circular(12),
value: _beacon.confidenceScore, border: Border.all(color: isVerified ? Colors.green.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.08)),
minHeight: 8, ),
backgroundColor: Colors.grey.withValues(alpha: 0.3), child: Row(
valueColor: AlwaysStoppedAnimation<Color>(_statusColor), 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), const SizedBox(height: 10),
Text( // Verification progress bar
'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%', ClipRRect(
style: AppTheme.labelSmall?.copyWith( borderRadius: BorderRadius.circular(4),
color: AppTheme.textDisabled, child: LinearProgressIndicator(
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),
),
),
],
),
);
}
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); await apiService.vouchBeacon(_post.id);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(content: Text('Thanks for confirming this report!'), backgroundColor: Colors.green),
content: Text('Thanks for confirming this beacon!'),
backgroundColor: Colors.green,
),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Something went wrong: $e'), backgroundColor: Colors.red),
content: Text('Something went wrong: $e'),
backgroundColor: Colors.red,
),
); );
} }
} finally { } finally {
@ -488,20 +405,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
await apiService.reportBeacon(_post.id); await apiService.reportBeacon(_post.id);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(content: Text('Report received. Thanks for keeping the community safe.'), backgroundColor: Colors.orange),
content: Text('Report received. Thanks for keeping the community safe.'),
backgroundColor: Colors.orange,
),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Something went wrong: $e'), backgroundColor: Colors.red),
content: Text('Something went wrong: $e'),
backgroundColor: Colors.red,
),
); );
} }
} finally { } 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> { class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
final ImageUploadService _imageUploadService = ImageUploadService(); final ImageUploadService _imageUploadService = ImageUploadService();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
BeaconType _selectedType = BeaconType.safety; BeaconType _selectedType = BeaconType.safety;
BeaconSeverity _selectedSeverity = BeaconSeverity.medium;
bool _isSubmitting = false; bool _isSubmitting = false;
bool _isUploadingImage = false; bool _isUploadingImage = false;
File? _selectedImage; File? _selectedImage;
String? _uploadedImageUrl; String? _uploadedImageUrl;
final List<BeaconType> _types = [ final List<BeaconType> _types = [
BeaconType.suspiciousActivity,
BeaconType.hazard,
BeaconType.fire,
BeaconType.police, BeaconType.police,
BeaconType.safety,
BeaconType.checkpoint, BeaconType.checkpoint,
BeaconType.taskForce, BeaconType.taskForce,
BeaconType.hazard,
BeaconType.safety,
BeaconType.community, BeaconType.community,
]; ];
@override @override
void dispose() { void dispose() {
_titleController.dispose();
_descriptionController.dispose(); _descriptionController.dispose();
super.dispose(); super.dispose();
} }
@ -96,12 +97,11 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
} }
Future<void> _submit() async { Future<void> _submit() async {
final title = _titleController.text.trim();
final description = _descriptionController.text.trim(); final description = _descriptionController.text.trim();
if (title.isEmpty || description.isEmpty) { if (description.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please add a title and details.')), const SnackBar(content: Text('Please describe what you see.')),
); );
return; return;
} }
@ -112,7 +112,6 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
final apiService = ref.read(apiServiceProvider); final apiService = ref.read(apiServiceProvider);
final body = _buildBeaconBody( final body = _buildBeaconBody(
title: title,
description: description, description: description,
lat: widget.centerLat, lat: widget.centerLat,
long: widget.centerLong, long: widget.centerLong,
@ -129,6 +128,7 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
beaconType: _selectedType, beaconType: _selectedType,
lat: widget.centerLat, lat: widget.centerLat,
long: widget.centerLong, long: widget.centerLong,
severity: _selectedSeverity.value,
); );
if (mounted) { if (mounted) {
@ -138,7 +138,7 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not create the beacon: $e')), SnackBar(content: Text('Could not create the report: $e')),
); );
} }
} finally { } finally {
@ -147,203 +147,242 @@ class _CreateBeaconSheetState extends ConsumerState<CreateBeaconSheet> {
} }
String _buildBeaconBody({ String _buildBeaconBody({
required String title,
required String description, required String description,
required double lat, required double lat,
required double long, required double long,
required BeaconType type, required BeaconType type,
}) { }) {
final locationLink = return description;
'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');
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final accentColor = theme.primaryColor;
return PopScope( return PopScope(
onPopInvoked: (didPop) { onPopInvoked: (didPop) {
if (_isSubmitting) return; if (_isSubmitting) return;
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: const BoxDecoration(
color: theme.cardColor, color: Color(0xFF1A1A2E),
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20,
left: 20, left: 20,
right: 20, right: 20,
top: 20, top: 16,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Drag handle
Center( Center(
child: Container( child: Container(
width: 40, width: 40, height: 4,
height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3), color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 16),
Text(
'Create a Beacon', // Header
style: theme.textTheme.headlineSmall?.copyWith( Row(
fontWeight: FontWeight.bold, children: [
), const Icon(Icons.warning_rounded, color: Colors.red, size: 24),
), const SizedBox(width: 8),
const SizedBox(height: 8), const Expanded(
Text( child: Text('Report Incident',
'Share an alert near ${widget.centerLat.toStringAsFixed(4)}, ${widget.centerLong.toStringAsFixed(4)}', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
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,
), ),
), IconButton(
], onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
const SizedBox(height: 16), icon: Icon(Icons.close, color: Colors.white.withValues(alpha: 0.6)),
TextFormField( ),
controller: _titleController, ],
decoration: const InputDecoration(
labelText: 'Title',
hintText: 'Short headline for the alert',
border: OutlineInputBorder(),
),
maxLength: 100,
), ),
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( TextFormField(
controller: _descriptionController, controller: _descriptionController,
decoration: const InputDecoration( style: const TextStyle(color: Colors.white, fontSize: 14),
labelText: 'Description', decoration: InputDecoration(
hintText: 'Add context that helps people nearby', hintText: 'Describe what you see...',
border: OutlineInputBorder(), 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, maxLength: 300,
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// Photo
if (_selectedImage != null) ...[ if (_selectedImage != null) ...[
Stack( Stack(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.file( child: Image.file(_selectedImage!, height: 120, width: double.infinity, fit: BoxFit.cover),
_selectedImage!,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
),
), ),
Positioned( Positioned(
top: 8, top: 6, right: 6,
right: 8,
child: IconButton( child: IconButton(
onPressed: _removeImage, onPressed: _removeImage,
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white, size: 18),
style: IconButton.styleFrom( style: IconButton.styleFrom(backgroundColor: Colors.black54, padding: const EdgeInsets.all(4)),
backgroundColor: Colors.black.withValues(alpha: 0.6),
),
), ),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
] else ...[ ] else ...[
OutlinedButton.icon( GestureDetector(
onPressed: _isUploadingImage ? null : _pickImage, onTap: _isUploadingImage ? null : _pickImage,
icon: _isUploadingImage child: Container(
? const SizedBox( padding: const EdgeInsets.symmetric(vertical: 10),
width: 20, decoration: BoxDecoration(
height: 20, color: const Color(0xFF16213E),
child: CircularProgressIndicator(strokeWidth: 2), borderRadius: BorderRadius.circular(10),
) border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
: const Icon(Icons.add_photo_alternate), ),
label: Text(_isUploadingImage child: Row(
? 'Uploading...' mainAxisAlignment: MainAxisAlignment.center,
: 'Add photo (optional)'), 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), const SizedBox(height: 16),
], ],
// Submit button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 48,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isSubmitting ? null : _submit, 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 child: _isSubmitting
? const SizedBox( ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
width: 20, : const Row(
height: 20, mainAxisAlignment: MainAxisAlignment.center,
child: CircularProgressIndicator(strokeWidth: 2), children: [
) Icon(Icons.warning_rounded, size: 18),
: const Text('Create Beacon'), SizedBox(width: 8),
), Text('Submit Report', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
), ],
const SizedBox(height: 12), ),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
), ),
), ),
const SizedBox(height: 8),
], ],
), ),
), ),

View file

@ -554,6 +554,7 @@ class ApiService {
BeaconType? beaconType, BeaconType? beaconType,
double? lat, double? lat,
double? long, double? long,
String? severity,
bool userWarned = false, bool userWarned = false,
bool isNsfw = false, bool isNsfw = false,
String? nsfwReason, String? nsfwReason,
@ -607,6 +608,7 @@ class ApiService {
if (beaconType != null) 'beacon_type': beaconType.value, if (beaconType != null) 'beacon_type': beaconType.value,
if (lat != null) 'beacon_lat': lat, if (lat != null) 'beacon_lat': lat,
if (long != null) 'beacon_long': long, if (long != null) 'beacon_long': long,
if (severity != null) 'severity': severity,
if (userWarned) 'user_warned': true, if (userWarned) 'user_warned': true,
if (isNsfw) 'is_nsfw': true, if (isNsfw) 'is_nsfw': true,
if (nsfwReason != null) 'nsfw_reason': nsfwReason, if (nsfwReason != null) 'nsfw_reason': nsfwReason,