MotorSignal-App/lib/features/social/feed/social_feed_screen.dart

1478 lines
52 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/responsive/responsive_layout.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/auth/auth_providers.dart';
import '../../../services/map/map_provider_interface.dart';
import '../../../services/social/social_feed_models.dart';
import '../../../services/social/social_feed_providers.dart';
import 'widgets/feed_comments_bottom_sheet.dart';
import 'widgets/share_session_modal.dart';
/// Community Social Feed & Kudos Screen (`SOC-01` / `MOB-19`).
///
/// Implements the Stitch canonical Community Social Feed HUD with telemetry
/// snapshot cards, custom route vector track visualization, apex angle markers,
/// glove-friendly fist bump kudos reactions, comment drawers, and session publishing.
class SocialFeedScreen extends ConsumerWidget {
final bool showAppBar;
const SocialFeedScreen({super.key, this.showAppBar = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final activeFilter = ref.watch(feedFilterStateProvider);
final items = ref.watch(socialFeedNotifierProvider);
final content = SafeArea(
child: ResponsiveLayout(
builder: (context, responsive) {
return Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Top Context Header & Filter Bar
_buildTopFilterSection(context, ref, activeFilter, colors),
// Feed List
Expanded(
child: items.isEmpty
? _buildEmptyFeedState(context, activeFilter, colors)
: ListView.separated(
key: const ValueKey('social_feed_list_view'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
itemBuilder: (context, index) {
return _SocialFeedCard(
key: ValueKey('feed_card_${items[index].id}'),
item: items[index],
);
},
),
),
],
),
),
),
// Sticky Glove-Friendly Share Session Action Bar
Positioned(
left: 16,
right: 16,
bottom: 12,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: _buildStickyShareButton(context),
),
),
),
],
);
},
),
);
if (!showAppBar) {
return Container(
color: const Color(0xFF101419),
child: content,
);
}
return Scaffold(
backgroundColor: const Color(0xFF101419),
appBar: AppBar(
backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.9),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/social');
}
},
),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.electricCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.dynamic_feed_rounded,
color: AppColors.electricCyan,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Community Feed',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: Colors.white,
),
),
Text(
'SQUAD & CANYON TELEMETRY',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: colors.outline,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded, color: Color(0xFF849396)),
tooltip: 'Refresh Feed',
onPressed: () {
ref.invalidate(socialFeedNotifierProvider);
ref.invalidate(socialFeedStreamProvider);
},
),
const SizedBox(width: 4),
],
),
body: content,
);
}
Widget _buildTopFilterSection(
BuildContext context,
WidgetRef ref,
FeedFilter activeFilter,
MotorSignalThemeColors colors,
) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
color: const Color(0xFF101419),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status LED and Live Metric Row
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
children: [
// Status LED pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFF181C21),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.electricCyan,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.electricCyan,
blurRadius: 6,
spreadRadius: 1,
),
],
),
),
const SizedBox(width: 8),
const Text(
'SQUAD & CANYON FEED',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Color(0xFF9CF0FF),
),
),
],
),
),
// Live sensor metric pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFF272A30),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.sensors_rounded,
size: 14,
color: AppColors.electricCyan,
),
SizedBox(width: 6),
Text(
'24 ACTIVE',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Color(0xFF9CF0FF),
),
),
],
),
),
],
),
const SizedBox(height: 10),
// Segmented Filter Control (3 tabs)
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFF0B0E13),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1D2025)),
),
child: Row(
children: [
_buildFilterTab(
context,
ref,
tab: FeedFilter.allActivity,
title: 'All Activity',
isSelected: activeFilter == FeedFilter.allActivity,
),
const SizedBox(width: 4),
_buildFilterTab(
context,
ref,
tab: FeedFilter.clubOnly,
title: 'Club Only',
isSelected: activeFilter == FeedFilter.clubOnly,
),
const SizedBox(width: 4),
_buildFilterTab(
context,
ref,
tab: FeedFilter.nearbyPacks,
title: 'Nearby Packs',
isSelected: activeFilter == FeedFilter.nearbyPacks,
),
],
),
),
],
),
);
}
Widget _buildFilterTab(
BuildContext context,
WidgetRef ref, {
required FeedFilter tab,
required String title,
required bool isSelected,
}) {
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
ref.read(feedFilterStateProvider.notifier).state = tab;
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? AppColors.electricCyan : Colors.transparent,
borderRadius: BorderRadius.circular(10),
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.electricCyan.withValues(alpha: 0.35),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
title,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
color: isSelected
? const Color(0xFF00363D)
: const Color(0xFFBAC9CC),
),
),
),
),
);
}
Widget _buildEmptyFeedState(
BuildContext context,
FeedFilter activeFilter,
MotorSignalThemeColors colors,
) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.dynamic_feed_outlined,
size: 56,
color: colors.outline.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
'No posts in ${activeFilter.displayName}',
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
'Hit "Share Recent Session" below to publish telemetry from your latest ride.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: colors.outline,
),
),
],
),
),
);
}
Widget _buildStickyShareButton(BuildContext context) {
return Container(
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.electricCyan.withValues(alpha: 0.45),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton(
key: const ValueKey('feed_share_session_button'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan,
foregroundColor: const Color(0xFF00363D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
onPressed: () => ShareSessionModal.show(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.add_circle_outline_rounded, size: 20),
SizedBox(width: 8),
Flexible(
child: Text(
'SHARE RECENT SESSION',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
),
],
),
),
);
}
}
/// Authoritative Feed Item Card (`SocialFeedCard`).
class _SocialFeedCard extends ConsumerStatefulWidget {
final SocialFeedItem item;
const _SocialFeedCard({super.key, required this.item});
@override
ConsumerState<_SocialFeedCard> createState() => _SocialFeedCardState();
}
class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
with SingleTickerProviderStateMixin {
late AnimationController _bumpAnimController;
late Animation<double> _bumpScaleAnimation;
int? _optimisticBumpCount;
bool? _optimisticIsBumped;
@override
void initState() {
super.initState();
_bumpAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 160),
);
_bumpScaleAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
CurvedAnimation(parent: _bumpAnimController, curve: Curves.easeOut),
);
}
@override
void didUpdateWidget(covariant _SocialFeedCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.item.fistBumpCount != widget.item.fistBumpCount ||
oldWidget.item.fistBumpUserIds != widget.item.fistBumpUserIds) {
_optimisticBumpCount = null;
_optimisticIsBumped = null;
}
}
@override
void dispose() {
_bumpAnimController.dispose();
super.dispose();
}
String _formatRelativeTime(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
String _formatDuration(int seconds) {
final mins = seconds ~/ 60;
if (mins < 60) {
final secs = seconds % 60;
return '${mins}m ${secs}s';
}
final hrs = mins ~/ 60;
final remMins = mins % 60;
return '${hrs}h ${remMins}m';
}
void _handleFistBump() {
final user = ref.read(currentUserProvider);
final uid = user?.uid ?? 'uid_rider';
final currentlyBumped =
_optimisticIsBumped ?? widget.item.isFistBumpedBy(uid);
final currentCount = _optimisticBumpCount ?? widget.item.fistBumpCount;
setState(() {
_optimisticIsBumped = !currentlyBumped;
_optimisticBumpCount = currentlyBumped
? (currentCount - 1).clamp(0, 999999)
: currentCount + 1;
});
_bumpAnimController.forward().then((_) {
if (mounted) _bumpAnimController.reverse();
});
ref.read(socialFeedServiceProvider).toggleFistBump(
postId: widget.item.id,
userUid: uid,
);
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final item = widget.item;
final user = ref.watch(currentUserProvider);
final isBumped =
_optimisticIsBumped ?? item.isFistBumpedBy(user?.uid ?? 'uid_rider');
final bumpCount = _optimisticBumpCount ?? item.fistBumpCount;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF1D2025),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFF272A30)),
boxShadow: const [
BoxShadow(
color: Colors.black45,
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Card Header: Author info, badge, club, overflow
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Helmet Avatar with online indicator
Stack(
children: [
CircleAvatar(
radius: 22,
backgroundColor: const Color(0xFF272A30),
child: const Icon(
Icons.sports_motorsports_rounded,
color: AppColors.electricCyan,
size: 26,
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 11,
height: 11,
decoration: BoxDecoration(
color: AppColors.electricCyan,
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF1D2025), width: 1.5),
boxShadow: const [
BoxShadow(
color: AppColors.electricCyan,
blurRadius: 4,
),
],
),
),
),
],
),
const SizedBox(width: 12),
// Author handle, badge, club, timestamp
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'@${item.authorHandle}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
color: Color(0xFFE0E2EA),
),
),
),
if (item.authorBadge != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.electricCyan
.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.authorBadge!,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: AppColors.electricCyan,
),
),
),
],
],
),
if (item.clubName != null) ...[
const SizedBox(height: 2),
Text(
item.clubName!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: Color(0xFFBAC9CC),
),
),
],
const SizedBox(height: 2),
Text(
'${_formatRelativeTime(item.timestamp)} • ${item.locationName}',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11,
color: colors.outline,
),
),
],
),
),
// Overflow options menu
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
color: Color(0xFF849396), size: 20),
color: const Color(0xFF181C21),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF272A30)),
),
onSelected: (val) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Action: $val'),
duration: const Duration(seconds: 1),
),
);
},
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'copy_link',
child: Text('Copy Route Link',
style: TextStyle(color: Colors.white, fontSize: 13)),
),
const PopupMenuItem(
value: 'export_gpx',
child: Text('Export GPX File',
style: TextStyle(color: Colors.white, fontSize: 13)),
),
const PopupMenuItem(
value: 'report',
child: Text('Report Content',
style:
TextStyle(color: Colors.redAccent, fontSize: 13)),
),
],
),
],
),
),
// Route Title & Motorcycle Tag
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.rideTitle,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: Color(0xFFC3F5FF),
),
),
const SizedBox(height: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF181C21),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF272A30)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.sports_motorsports_rounded,
size: 14,
color: AppColors.electricCyan,
),
const SizedBox(width: 6),
Flexible(
child: Text(
item.bikeModel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFFE0E2EA),
),
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
// Telemetry Inset Strip (Stitch 2x2 Inset Grid)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF0B0E13),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF181C21)),
),
child: Column(
children: [
Row(
children: [
_buildMetricCell(
label: 'DISTANCE',
value: item.distanceMiles.toStringAsFixed(1),
unit: 'MI',
valueColor: Colors.white,
),
const SizedBox(width: 6),
_buildMetricCell(
label: 'DURATION',
value: _formatDuration(item.durationSeconds),
unit: '',
valueColor: Colors.white,
),
],
),
const SizedBox(height: 6),
Row(
children: [
_buildMetricCell(
label: 'MAX LEAN',
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
unit: item.maxLeanSide,
valueColor: AppColors.electricCyan,
indicatorColor: AppColors.electricCyan,
),
const SizedBox(width: 6),
_buildMetricCell(
label: 'PACE',
value: item.paceFactor.split(' ').first,
unit: item.paceFactor.split(' ').length > 1
? item.paceFactor.split(' ').sublist(1).join(' ')
: '',
valueColor: const Color(0xFFB4C5FF),
),
],
),
],
),
),
),
const SizedBox(height: 12),
// Visual Snapshot Module (Route Vector Track or Scenic Photo)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: item.snapshotType == FeedSnapshotType.scenicPhoto
? _buildScenicPhotoModule(context, item)
: _buildRouteTrackModule(context, item),
),
const SizedBox(height: 12),
// Kudos & Action Bar (Fist Bump, Comments, GPX, Inspect)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Column(
children: [
Row(
children: [
// Fist Bump Interactive Button
Expanded(
flex: 3,
child: ScaleTransition(
scale: _bumpScaleAnimation,
child: InkWell(
key: ValueKey('fist_bump_button_${item.id}'),
borderRadius: BorderRadius.circular(12),
onTap: _handleFistBump,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 48,
decoration: BoxDecoration(
color: isBumped
? AppColors.electricCyan
: const Color(0xFF272A30),
borderRadius: BorderRadius.circular(12),
boxShadow: isBumped
? [
BoxShadow(
color: AppColors.electricCyan
.withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('👊', style: TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Flexible(
child: Text(
'$bumpCount Bumps',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: isBumped
? const Color(0xFF00363D)
: Colors.white,
),
),
),
],
),
),
),
),
),
const SizedBox(width: 8),
// Comments Action Button
Expanded(
flex: 2,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () =>
FeedCommentsBottomSheet.show(context, item),
child: Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF272A30),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.chat_bubble_outline_rounded,
size: 18,
color: AppColors.electricCyan,
),
const SizedBox(width: 6),
Text(
'${item.comments.length}',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
const SizedBox(width: 8),
// GPX Export Button
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF1D2025),
content: Row(
children: [
const Icon(Icons.download_done_rounded,
color: AppColors.electricCyan),
const SizedBox(width: 10),
Text(
'GPX track exported for ${item.rideTitle}',
style: const TextStyle(color: Colors.white),
),
],
),
),
);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF272A30),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.download_rounded,
size: 20,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
// Telemetry Deep Dive Inspector Trigger
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
// Navigate to replay or show telemetry graph dialog
context.push('/replay');
},
child: Container(
height: 44,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF181C21),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(
Icons.bolt_rounded,
size: 18,
color: AppColors.electricCyan,
),
SizedBox(width: 8),
Flexible(
child: Text(
'INSPECT TELEMETRY GRAPH',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: AppColors.electricCyan,
),
),
),
],
),
),
),
],
),
),
],
),
);
}
Widget _buildMetricCell({
required String label,
required String value,
required String unit,
required Color valueColor,
Color? indicatorColor,
}) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF181C21),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: Color(0xFF849396),
),
),
),
if (indicatorColor != null) ...[
Container(
width: 5,
height: 5,
decoration: BoxDecoration(
color: indicatorColor,
shape: BoxShape.circle,
),
),
],
],
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
fontWeight: FontWeight.w800,
color: valueColor,
),
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 3),
Text(
unit,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w600,
color: Color(0xFFBAC9CC),
),
),
],
],
),
],
),
),
);
}
Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) {
return Container(
height: 160,
decoration: BoxDecoration(
color: const Color(0xFF0B0E13),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF181C21)),
),
child: Stack(
children: [
// Route Vector Canvas Painter
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: CustomPaint(
painter: _FeedRoutePainter(
points: item.routePoints,
maxLean: item.maxLeanAngleDeg,
),
),
),
),
// Elevation Pill Overlay
Positioned(
bottom: 8,
left: 10,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF1D2025).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.landscape_rounded,
size: 13,
color: AppColors.electricCyan,
),
const SizedBox(width: 5),
Text(
'ELV +${item.elevationFt} FT',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w700,
color: Color(0xFFE0E2EA),
),
),
],
),
),
),
// GPS Verified Badge
if (item.isGpsVerified) ...[
Positioned(
top: 8,
right: 10,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF101419).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.verified_rounded,
size: 12, color: AppColors.electricCyan),
SizedBox(width: 4),
Text(
'GPS VERIFIED',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w800,
color: AppColors.electricCyan,
),
),
],
),
),
),
],
],
),
);
}
Widget _buildScenicPhotoModule(BuildContext context, SocialFeedItem item) {
return Container(
height: 180,
decoration: BoxDecoration(
color: const Color(0xFF0B0E13),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF181C21)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
// Atmospheric gradient container mimicking canyon overlook
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1E293B),
Color(0xFF0F172A),
Color(0xFF020617),
],
),
),
child: const Center(
child: Icon(
Icons.landscape_rounded,
size: 64,
color: Color(0xFF334155),
),
),
),
// Bottom gradient vignette
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.75),
],
),
),
),
),
// Lookout caption & GPS badge
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF101419).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.photo_camera_rounded,
size: 13,
color: AppColors.electricCyan,
),
const SizedBox(width: 5),
Text(
item.photoCaption ?? 'Scenic Overlook',
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF101419).withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.verified_rounded,
size: 13,
color: AppColors.electricCyan,
),
SizedBox(width: 4),
Text(
'GPS VERIFIED',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
color: AppColors.electricCyan,
),
),
],
),
),
],
),
),
],
),
),
);
}
}
/// Custom vector route painter for the community social feed route preview.
class _FeedRoutePainter extends CustomPainter {
final List<AppLatLng> points;
final double maxLean;
const _FeedRoutePainter({
required this.points,
required this.maxLean,
});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
// 1. Grid lines matrix overlay
final gridPaint = Paint()
..color = const Color(0xFF1D2025)
..strokeWidth = 0.8;
const gridSize = 24.0;
for (double x = 0; x < w; x += gridSize) {
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
}
for (double y = 0; y < h; y += gridSize) {
canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint);
}
// 2. High-contrast curved canyon path
final path = Path();
final p0 = Offset(w * 0.1, h * 0.75);
final p1 = Offset(w * 0.35, h * 0.25);
final p2 = Offset(w * 0.55, h * 0.7);
final p3 = Offset(w * 0.88, h * 0.3);
path.moveTo(p0.dx, p0.dy);
path.cubicTo(
w * 0.2, h * 0.75,
w * 0.25, h * 0.25,
p1.dx, p1.dy,
);
path.cubicTo(
w * 0.42, h * 0.25,
w * 0.45, h * 0.7,
p2.dx, p2.dy,
);
path.cubicTo(
w * 0.65, h * 0.7,
w * 0.72, h * 0.28,
p3.dx, p3.dy,
);
// Cyan glow blur
final glowPaint = Paint()
..color = AppColors.electricCyan.withValues(alpha: 0.25)
..strokeWidth = 8
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawPath(path, glowPaint);
// Active trail
final trailPaint = Paint()
..color = const Color(0xFFC3F5FF)
..strokeWidth = 3.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawPath(path, trailPaint);
// High G-Force Apex Segment Highlight (Amber Sector)
final amberPath = Path();
amberPath.moveTo(p1.dx, p1.dy);
amberPath.cubicTo(
w * 0.42, h * 0.25,
w * 0.45, h * 0.7,
p2.dx, p2.dy,
);
final amberPaint = Paint()
..color = const Color(0xFFF59E0B)
..strokeWidth = 4.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawPath(amberPath, amberPaint);
// Start marker with dashed ring
final startPaint = Paint()..color = AppColors.electricCyan;
canvas.drawCircle(p0, 4.5, startPaint);
final ringPaint = Paint()
..color = AppColors.electricCyan.withValues(alpha: 0.6)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawCircle(p0, 8.0, ringPaint);
// Apex markers with badges
final apexPaint = Paint()..color = AppColors.electricCyan;
canvas.drawCircle(p1, 4.0, apexPaint);
final amberDotPaint = Paint()..color = const Color(0xFFF59E0B);
canvas.drawCircle(p2, 4.0, amberDotPaint);
// Finish marker
final finishPaint = Paint()..color = const Color(0xFFBAC9CC);
canvas.drawCircle(p3, 4.0, finishPaint);
// Text labels for APEX and FINISH
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: '${maxLean.toStringAsFixed(0)}° APEX',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w800,
color: Color(0xFFC3F5FF),
),
);
textPainter.layout();
textPainter.paint(canvas, Offset(p1.dx - textPainter.width / 2, p1.dy - 16));
textPainter.text = const TextSpan(
text: '48° SWEEP',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w800,
color: Color(0xFFF59E0B),
),
);
textPainter.layout();
textPainter.paint(canvas, Offset(p2.dx - textPainter.width / 2, p2.dy + 8));
textPainter.text = const TextSpan(
text: 'FINISH',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFFBAC9CC),
),
);
textPainter.layout();
textPainter.paint(canvas, Offset(p3.dx - textPainter.width / 2, p3.dy + 8));
}
@override
bool shouldRepaint(covariant _FeedRoutePainter oldDelegate) {
return oldDelegate.maxLean != maxLean || oldDelegate.points != points;
}
}