feat: beacon tactical dashboard - dark map-first UI, DraggableScrollableSheet, severity pins, 'I see this too' verification, incident categories
This commit is contained in:
parent
442b4bef32
commit
5780f0ff75
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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<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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: 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<Color>(_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<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);
|
||||
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
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue