sojorn/sojorn_app/lib/models/beacon.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

245 lines
7.3 KiB
Dart

import 'package:flutter/material.dart';
/// Beacon type enum for different alert categories
/// Uses neutral naming for App Store compliance
enum BeaconType {
police('police', 'Police Presence', 'General presence (Speed traps, patrol)', Icons.local_police, Colors.blue),
checkpoint('checkpoint', 'Checkpoint / Stop', 'Report stationary stops, roadblocks, or inspection points.', Icons.stop_circle, Colors.indigo),
taskForce('taskForce', 'Task Force / Operation', 'Report heavy coordinated activity, raids, or multiple units.', Icons.warning, Colors.deepOrange),
hazard('hazard', 'Road Hazard', 'Physical danger (Debris, Ice, Floods)', Icons.report_problem, Colors.amber),
safety('safety', 'Safety Alert', 'Events (Fire, Crime, Fights)', Icons.shield, Colors.red),
community('community', 'Community Event', 'Helpful (Food drives, Lost pets)', Icons.volunteer_activism, Colors.teal);
final String value;
final String displayName;
final String description;
final IconData icon;
final Color color;
const BeaconType(this.value, this.displayName, this.description, this.icon, this.color);
static BeaconType fromString(String value) {
return BeaconType.values.firstWhere(
(type) => type.value == value,
orElse: () => BeaconType.community,
);
}
/// Get helper text for checkpoint and task force types
String? get helperText {
switch (this) {
case BeaconType.checkpoint:
return 'Report stationary stops, roadblocks, or inspection points.';
case BeaconType.taskForce:
return 'Report heavy coordinated activity, raids, or multiple units.';
default:
return null;
}
}
}
/// Beacon status color based on confidence score
enum BeaconStatus {
green('green', 'Verified', 'High confidence - Community verified'),
yellow('yellow', 'Caution', 'Pending verification'),
red('red', 'Unverified', 'Low confidence - Needs verification');
final String value;
final String label;
final String description;
const BeaconStatus(this.value, this.label, this.description);
static BeaconStatus fromConfidence(double score) {
if (score > 0.7) {
return BeaconStatus.green;
} else if (score >= 0.3 && score <= 0.7) {
return BeaconStatus.yellow;
} else {
return BeaconStatus.red;
}
}
}
/// Beacon model representing a location-based safety/community alert
class Beacon {
final String id;
final String body;
final String authorId;
final BeaconType beaconType;
final double confidenceScore;
final bool isActiveBeacon;
final BeaconStatus status;
final DateTime createdAt;
final double distanceMeters;
final String? imageUrl;
// Location info
final double? beaconLat;
final double? beaconLong;
// Author info
final String? authorHandle;
final String? authorDisplayName;
final String? authorAvatarUrl;
// Vote info
final int? vouchCount;
final int? reportCount;
final String? userVote; // 'vouch', 'report', or null
Beacon({
required this.id,
required this.body,
required this.authorId,
required this.beaconType,
required this.confidenceScore,
required this.isActiveBeacon,
required this.status,
required this.createdAt,
this.distanceMeters = 0,
this.imageUrl,
this.beaconLat,
this.beaconLong,
this.authorHandle,
this.authorDisplayName,
this.authorAvatarUrl,
this.vouchCount,
this.reportCount,
this.userVote,
});
/// Parse double from various types
static double _parseDouble(dynamic value, {double fallback = 0.0}) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is num) return value.toDouble();
return fallback;
}
/// Parse int from various types
static int _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return 0;
}
factory Beacon.fromJson(Map<String, dynamic> json) {
final statusColor = json['status_color'] as String? ?? 'yellow';
final status = _getStatusFromColor(statusColor);
final beaconType = BeaconType.fromString(json['beacon_type'] as String? ?? 'community');
return Beacon(
id: json['id'] as String,
body: json['body'] as String,
authorId: json['author_id'] as String? ?? '',
beaconType: beaconType,
confidenceScore: _parseDouble(json['confidence_score']),
isActiveBeacon: json['is_active_beacon'] as bool? ?? true,
status: status,
createdAt: DateTime.parse(json['created_at'] as String),
distanceMeters: _parseDouble(json['distance_meters']),
imageUrl: json['image_url'] as String?,
beaconLat: json['beacon_lat'] != null ? _parseDouble(json['beacon_lat']) : null,
beaconLong: json['beacon_long'] != null ? _parseDouble(json['beacon_long']) : null,
authorHandle: json['author_handle'] as String?,
authorDisplayName: json['author_display_name'] as String?,
authorAvatarUrl: json['author_avatar_url'] as String?,
vouchCount: _parseInt(json['vouch_count']),
reportCount: _parseInt(json['report_count']),
userVote: json['user_vote'] as String?,
);
}
static BeaconStatus _getStatusFromColor(String color) {
switch (color) {
case 'green':
return BeaconStatus.green;
case 'yellow':
return BeaconStatus.yellow;
case 'red':
default:
return BeaconStatus.red;
}
}
/// Get human-readable distance
String getFormattedDistance() {
if (distanceMeters < 1000) {
return '${distanceMeters.round()}m';
} else {
final km = distanceMeters / 1000;
return '${km.toStringAsFixed(1)}km';
}
}
/// Get time ago string
String getTimeAgo() {
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';
}
}
Map<String, dynamic> toJson() {
return {
'id': id,
'body': body,
'author_id': authorId,
'beacon_type': beaconType.value,
'confidence_score': confidenceScore,
'is_active_beacon': isActiveBeacon,
'status_color': status.value,
'created_at': createdAt.toIso8601String(),
'distance_meters': distanceMeters,
'image_url': imageUrl,
'beacon_lat': beaconLat,
'beacon_long': beaconLong,
'author_handle': authorHandle,
'author_display_name': authorDisplayName,
'author_avatar_url': authorAvatarUrl,
'vouch_count': vouchCount,
'report_count': reportCount,
'user_vote': userVote,
};
}
}
/// Beacon creation request
class CreateBeaconRequest {
final double lat;
final double long;
final String title;
final String description;
final BeaconType type;
final String? imageUrl;
CreateBeaconRequest({
required this.lat,
required this.long,
required this.title,
required this.description,
required this.type,
this.imageUrl,
});
Map<String, dynamic> toJson() {
return {
'lat': lat,
'long': long,
'title': title,
'description': description,
'type': type.value,
if (imageUrl != null) 'image_url': imageUrl,
};
}
}