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 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
width: 40, height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withValues(alpha: 0.2),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Severity + LIVE badges row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: severityColor.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: statusColor),
|
border: Border.all(color: severityColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(statusColor == Colors.green ? Icons.verified : Icons.warning,
|
Icon(beacon.severity.icon, size: 14, color: severityColor),
|
||||||
size: 16, color: statusColor),
|
const SizedBox(width: 4),
|
||||||
const SizedBox(width: 6),
|
Text(beacon.severity.label,
|
||||||
Text(
|
style: TextStyle(color: severityColor, fontWeight: FontWeight.bold, fontSize: 11)),
|
||||||
statusLabel,
|
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
|
||||||
color: statusColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(width: 8),
|
||||||
// Beacon type
|
if (isRecent)
|
||||||
Text(
|
Container(
|
||||||
post.beaconType?.displayName ?? 'Community',
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)),
|
||||||
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
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,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 42,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isVouching ? null : () => _vouch(post.id),
|
onPressed: _isVouching ? null : () => _vouch(post.id),
|
||||||
icon: _isVouching
|
icon: _isVouching
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
: const Icon(Icons.thumb_up, size: 18),
|
: const Icon(Icons.visibility, size: 16),
|
||||||
label: const Text('Vouch'),
|
label: Text(_isVouching ? '...' : 'I see this too', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
foregroundColor: Colors.green,
|
backgroundColor: Colors.green.shade700,
|
||||||
side: const BorderSide(color: Colors.green),
|
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(
|
||||||
|
flex: 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 42,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _isReporting ? null : () => _report(post.id),
|
onPressed: _isReporting ? null : () => _report(post.id),
|
||||||
icon: _isReporting
|
icon: _isReporting
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red))
|
||||||
: const Icon(Icons.thumb_down, size: 18),
|
: Icon(Icons.flag, size: 16, color: Colors.red.withValues(alpha: 0.7)),
|
||||||
label: const Text('Report'),
|
label: Text(_isReporting ? '...' : 'False alarm',
|
||||||
|
style: TextStyle(color: Colors.red.withValues(alpha: 0.7), fontSize: 12)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Colors.red,
|
side: BorderSide(color: Colors.red.withValues(alpha: 0.3)),
|
||||||
side: const BorderSide(color: Colors.red),
|
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF16213E),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
_metaItem(Icons.schedule, _beacon.getTimeAgo()),
|
||||||
Icons.access_time,
|
_metaItem(Icons.location_on, _beacon.getFormattedDistance()),
|
||||||
color: AppTheme.textDisabled,
|
_metaItem(Icons.visibility, '${_beacon.verificationCount} verified'),
|
||||||
size: 16,
|
_metaItem(Icons.radar, '${_beacon.radius}m radius'),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAuthorInfo() {
|
Widget _metaItem(IconData icon, String label) {
|
||||||
// Beacons are anonymous — no author info displayed
|
return Column(
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButtons() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
// Vouch Button
|
Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.4)),
|
||||||
SizedBox(
|
const SizedBox(height: 4),
|
||||||
width: double.infinity,
|
Text(label, style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 10)),
|
||||||
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(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF16213E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: isVerified ? Colors.green.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.08)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(isVerified ? Icons.verified : Icons.pending,
|
||||||
|
color: isVerified ? Colors.green : Colors.amber, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(isVerified ? 'Verified by community' : 'Awaiting verification',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isVerified ? Colors.green : Colors.amber,
|
||||||
|
fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('$verCount / 3 neighbors confirmed this report',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Verification progress bar
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: _beacon.confidenceScore,
|
value: (verCount / 3).clamp(0.0, 1.0),
|
||||||
minHeight: 8,
|
minHeight: 4,
|
||||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
backgroundColor: Colors.white.withValues(alpha: 0.1),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(_statusColor),
|
valueColor: AlwaysStoppedAnimation<Color>(isVerified ? Colors.green : Colors.amber),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
],
|
||||||
Text(
|
),
|
||||||
'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%',
|
);
|
||||||
style: AppTheme.labelSmall?.copyWith(
|
}
|
||||||
color: AppTheme.textDisabled,
|
|
||||||
|
Widget _buildActionButtons(Color severityColor) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// "I see this too" button — primary action
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isVouching ? null : _vouchBeacon,
|
||||||
|
icon: _isVouching
|
||||||
|
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Icon(Icons.visibility, size: 20),
|
||||||
|
label: Text(_isVouching ? 'Confirming...' : 'I see this too',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green.shade700,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
disabledBackgroundColor: Colors.green.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// False alarm / report button — secondary action
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isReporting ? null : _reportBeacon,
|
||||||
|
icon: _isReporting
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red))
|
||||||
|
: Icon(Icons.flag, size: 18, color: Colors.red.withValues(alpha: 0.7)),
|
||||||
|
label: Text(_isReporting ? 'Reporting...' : 'False alarm / Report',
|
||||||
|
style: TextStyle(color: Colors.red.withValues(alpha: 0.7), fontSize: 13)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: Colors.red.withValues(alpha: 0.3)),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -459,20 +382,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
|
||||||
await apiService.vouchBeacon(_post.id);
|
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
|
|
@ -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(),
|
||||||
|
icon: Icon(Icons.close, color: Colors.white.withValues(alpha: 0.6)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _titleController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Title',
|
|
||||||
hintText: 'Short headline for the alert',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
maxLength: 100,
|
const SizedBox(height: 4),
|
||||||
|
Text('Near ${widget.centerLat.toStringAsFixed(4)}, ${widget.centerLong.toStringAsFixed(4)}',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 12)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Incident type — grid of preset buttons
|
||||||
|
Text('What\'s happening?',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _types.map((type) {
|
||||||
|
final isSelected = type == _selectedType;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedType = type),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? type.color.withValues(alpha: 0.25) : const Color(0xFF16213E),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? type.color : Colors.white.withValues(alpha: 0.1),
|
||||||
|
width: isSelected ? 1.5 : 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(type.icon, size: 16, color: isSelected ? type.color : Colors.white.withValues(alpha: 0.6)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(type.displayName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? type.color : Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: 12, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Severity selector
|
||||||
|
Text('Severity',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: BeaconSeverity.values.map((sev) {
|
||||||
|
final isSelected = sev == _selectedSeverity;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedSeverity = sev),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? sev.color.withValues(alpha: 0.25) : const Color(0xFF16213E),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(sev.icon, size: 18, color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(sev.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? sev.color : Colors.white.withValues(alpha: 0.5),
|
||||||
|
fontSize: 10, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Description
|
||||||
TextFormField(
|
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)),
|
||||||
),
|
),
|
||||||
maxLines: 4,
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: const BorderSide(color: Colors.red, width: 1),
|
||||||
|
),
|
||||||
|
counterStyle: TextStyle(color: Colors.white.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
maxLength: 300,
|
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),
|
|
||||||
] else ...[
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _isUploadingImage ? null : _pickImage,
|
|
||||||
icon: _isUploadingImage
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.add_photo_alternate),
|
|
||||||
label: Text(_isUploadingImage
|
|
||||||
? 'Uploading...'
|
|
||||||
: 'Add photo (optional)'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
child: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Create Beacon'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
] else ...[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _isUploadingImage ? null : _pickImage,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF16213E),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (_isUploadingImage)
|
||||||
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54))
|
||||||
|
else
|
||||||
|
Icon(Icons.add_photo_alternate, size: 18, color: Colors.white.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_isUploadingImage ? 'Uploading...' : 'Add photo evidence',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Submit button
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: TextButton(
|
height: 48,
|
||||||
onPressed:
|
child: ElevatedButton(
|
||||||
_isSubmitting ? null : () => Navigator.of(context).pop(),
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
child: const Text('Cancel'),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
disabledBackgroundColor: Colors.red.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
child: _isSubmitting
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_rounded, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Submit Report', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue