2045 lines
74 KiB
Dart
2045 lines
74 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/feed_telemetry_inspector_modal.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.
|
|
/// glove-friendly fist bump kudos reactions, comment drawers, and session publishing.
|
|
class SocialFeedScreen extends ConsumerStatefulWidget {
|
|
final bool showAppBar;
|
|
|
|
const SocialFeedScreen({super.key, this.showAppBar = true});
|
|
|
|
@override
|
|
ConsumerState<SocialFeedScreen> createState() => _SocialFeedScreenState();
|
|
}
|
|
|
|
class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
|
bool _isSearchExpanded = false;
|
|
late final TextEditingController _searchController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchController = TextEditingController(
|
|
text: ref.read(feedSearchQueryProvider),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final activeFilter = ref.watch(feedFilterStateProvider);
|
|
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
|
|
final searchQuery = ref.watch(feedSearchQueryProvider);
|
|
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, Multi-Stream Bar & Discipline Chips
|
|
_buildTopFilterSection(
|
|
context,
|
|
activeFilter,
|
|
activeDiscipline,
|
|
searchQuery,
|
|
colors,
|
|
),
|
|
|
|
// Feed List
|
|
Expanded(
|
|
child: items.isEmpty
|
|
? _buildEmptyFeedState(
|
|
context,
|
|
activeFilter,
|
|
activeDiscipline,
|
|
searchQuery,
|
|
colors,
|
|
)
|
|
: ListView.separated(
|
|
key: const ValueKey('social_feed_list_view'),
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final secondaryTextColor =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
final accentColor =
|
|
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
|
|
|
if (!widget.showAppBar) {
|
|
return Container(
|
|
color: colors.surfaceCanvas,
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: colors.surfaceCanvas,
|
|
appBar: AppBar(
|
|
backgroundColor: colors.surfaceCanvas,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: Icon(Icons.arrow_back_rounded, color: colors.textPrimary),
|
|
onPressed: () {
|
|
if (context.canPop()) {
|
|
context.pop();
|
|
} else {
|
|
context.go('/social');
|
|
}
|
|
},
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: accentColor.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.dynamic_feed_rounded,
|
|
color: accentColor,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Community Feed',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
Text(
|
|
'SQUAD & CANYON TELEMETRY',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.8,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_isSearchExpanded ? Icons.search_off_rounded : Icons.search_rounded,
|
|
color: _isSearchExpanded ? accentColor : secondaryTextColor,
|
|
),
|
|
tooltip: 'Search Routes',
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSearchExpanded = !_isSearchExpanded;
|
|
if (!_isSearchExpanded) {
|
|
_searchController.clear();
|
|
ref.read(feedSearchQueryProvider.notifier).state = '';
|
|
}
|
|
});
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.refresh_rounded, color: secondaryTextColor),
|
|
tooltip: 'Refresh Feed',
|
|
onPressed: () {
|
|
ref.invalidate(socialFeedNotifierProvider);
|
|
ref.invalidate(socialFeedStreamProvider);
|
|
},
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
),
|
|
body: content,
|
|
);
|
|
}
|
|
|
|
Widget _buildTopFilterSection(
|
|
BuildContext context,
|
|
FeedFilter activeFilter,
|
|
RidingDiscipline activeDiscipline,
|
|
String searchQuery,
|
|
MotorSignalThemeColors colors,
|
|
) {
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final secondaryTextColor =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
final accentColor =
|
|
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
|
|
color: colors.surfaceCanvas,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: Status LED & Search toggle
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Status LED pill
|
|
Flexible(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: accentColor.withValues(alpha: isLight ? 0.5 : 0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: accentColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
'SQUAD & CANYON FEED',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.6,
|
|
color: accentColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Search toggle button
|
|
IconButton(
|
|
key: const ValueKey('feed_search_toggle_button'),
|
|
icon: Icon(
|
|
_isSearchExpanded ? Icons.close_rounded : Icons.search_rounded,
|
|
color: accentColor,
|
|
size: 20,
|
|
),
|
|
tooltip: 'Search Feed',
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSearchExpanded = !_isSearchExpanded;
|
|
if (!_isSearchExpanded) {
|
|
_searchController.clear();
|
|
ref.read(feedSearchQueryProvider.notifier).state = '';
|
|
}
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
// Optional Search Input Field
|
|
if (_isSearchExpanded) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardPrimary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: accentColor.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
autofocus: true,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 13,
|
|
color: colors.textPrimary,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: 'Search rider, route, bike, or region...',
|
|
hintStyle: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 13,
|
|
color: secondaryTextColor,
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.search_rounded,
|
|
size: 18,
|
|
color: secondaryTextColor,
|
|
),
|
|
suffixIcon: _searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.close_rounded, size: 16),
|
|
color: secondaryTextColor,
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
ref.read(feedSearchQueryProvider.notifier).state = '';
|
|
setState(() {});
|
|
},
|
|
)
|
|
: null,
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
|
),
|
|
onChanged: (val) {
|
|
ref.read(feedSearchQueryProvider.notifier).state = val;
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Unified Single Streamlined Filter Row (Streams + Disciplines)
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildFilterTab(
|
|
tab: FeedFilter.allActivity,
|
|
title: 'All Activity',
|
|
isSelected: activeFilter == FeedFilter.allActivity,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildFilterTab(
|
|
tab: FeedFilter.clubOnly,
|
|
title: 'Club Only',
|
|
isSelected: activeFilter == FeedFilter.clubOnly,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildFilterTab(
|
|
tab: FeedFilter.nearbyPacks,
|
|
title: 'Nearby Packs',
|
|
isSelected: activeFilter == FeedFilter.nearbyPacks,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildFilterTab(
|
|
tab: FeedFilter.forYou,
|
|
title: 'For You',
|
|
isSelected: activeFilter == FeedFilter.forYou,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildFilterTab(
|
|
tab: FeedFilter.following,
|
|
title: 'Following',
|
|
isSelected: activeFilter == FeedFilter.following,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
width: 1,
|
|
height: 20,
|
|
color: colors.surfaceBorder,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.all,
|
|
label: 'All Styles',
|
|
isSelected: activeDiscipline == RidingDiscipline.all,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.canyonSport,
|
|
label: '⚡ Canyon',
|
|
isSelected: activeDiscipline == RidingDiscipline.canyonSport,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.advDualSport,
|
|
label: '🌲 ADV & Dirt',
|
|
isSelected: activeDiscipline == RidingDiscipline.advDualSport,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.touring,
|
|
label: '🛣️ Touring',
|
|
isSelected: activeDiscipline == RidingDiscipline.touring,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.trackDay,
|
|
label: '🏁 Track Day',
|
|
isSelected: activeDiscipline == RidingDiscipline.trackDay,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildDisciplineChip(
|
|
discipline: RidingDiscipline.cruiser,
|
|
label: '🏍️ Cruiser',
|
|
isSelected: activeDiscipline == RidingDiscipline.cruiser,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Filter Summary & Reset Bar (When disciplined or searched)
|
|
if (activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.filter_alt_outlined,
|
|
size: 13,
|
|
color: accentColor,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Filtered by: ${activeDiscipline != RidingDiscipline.all ? activeDiscipline.displayName : ''}${activeDiscipline != RidingDiscipline.all && searchQuery.isNotEmpty ? ', ' : ''}${searchQuery.isNotEmpty ? '"$searchQuery"' : ''}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 11,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () {
|
|
ref.read(feedDisciplineFilterProvider.notifier).state =
|
|
RidingDiscipline.all;
|
|
ref.read(feedSearchQueryProvider.notifier).state = '';
|
|
_searchController.clear();
|
|
setState(() {
|
|
_isSearchExpanded = false;
|
|
});
|
|
},
|
|
child: Text(
|
|
'Reset',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: accentColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterTab({
|
|
required FeedFilter tab,
|
|
required String title,
|
|
required bool isSelected,
|
|
}) {
|
|
final colors = context.motorColors;
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
|
final selectedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E);
|
|
final unselectedFg =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: () {
|
|
ref.read(feedFilterStateProvider.notifier).state = tab;
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeInOut,
|
|
height: 36,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? selectedBg : colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: isSelected ? selectedBg : colors.surfaceBorder,
|
|
),
|
|
),
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 11.5,
|
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
|
letterSpacing: 0.1,
|
|
color: isSelected ? selectedFg : unselectedFg,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDisciplineChip({
|
|
required RidingDiscipline discipline,
|
|
required String label,
|
|
required bool isSelected,
|
|
}) {
|
|
final colors = context.motorColors;
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
|
final selectedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E);
|
|
final unselectedFg =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () {
|
|
ref.read(feedDisciplineFilterProvider.notifier).state = discipline;
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
height: 36,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? selectedBg : colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isSelected ? selectedBg : colors.surfaceBorder,
|
|
width: isSelected ? 1.2 : 1.0,
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 11.5,
|
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
|
color: isSelected ? selectedFg : unselectedFg,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyFeedState(
|
|
BuildContext context,
|
|
FeedFilter activeFilter,
|
|
RidingDiscipline activeDiscipline,
|
|
String searchQuery,
|
|
MotorSignalThemeColors colors,
|
|
) {
|
|
final hasCustomFilters =
|
|
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final secondaryTextColor =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
final accentColor =
|
|
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SingleChildScrollView(
|
|
physics: const ClampingScrollPhysics(),
|
|
padding: const EdgeInsets.only(bottom: 80),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: constraints.maxHeight > 80
|
|
? constraints.maxHeight - 80
|
|
: constraints.maxHeight,
|
|
),
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.dynamic_feed_outlined,
|
|
size: 56,
|
|
color: secondaryTextColor,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
hasCustomFilters
|
|
? 'No matching ride sessions'
|
|
: 'No posts in ${activeFilter.displayName}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
hasCustomFilters
|
|
? 'Try relaxing your style filters or search query to discover more community rides.'
|
|
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 13,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
key: const ValueKey('empty_feed_group_rides_button'),
|
|
icon: const Icon(Icons.two_wheeler_rounded, size: 16),
|
|
label: const Text('Group Rides'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: accentColor,
|
|
side: BorderSide(
|
|
color: accentColor.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
onPressed: () => context.push('/group-rides'),
|
|
),
|
|
OutlinedButton.icon(
|
|
key: const ValueKey('empty_feed_clubs_button'),
|
|
icon: const Icon(Icons.groups_rounded, size: 16),
|
|
label: const Text('Clubs'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: accentColor,
|
|
side: BorderSide(
|
|
color: accentColor.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
onPressed: () => context.push('/social/clubs'),
|
|
),
|
|
OutlinedButton.icon(
|
|
key: const ValueKey('empty_feed_friends_button'),
|
|
icon: const Icon(Icons.person_add_rounded, size: 16),
|
|
label: const Text('Friends'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: accentColor,
|
|
side: BorderSide(
|
|
color: accentColor.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
onPressed: () => context.push('/social/friends'),
|
|
),
|
|
],
|
|
),
|
|
if (hasCustomFilters) ...[
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.refresh_rounded, size: 16),
|
|
label: const Text('Reset All Filters'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: accentColor,
|
|
side: BorderSide(
|
|
color: accentColor.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
ref.read(feedDisciplineFilterProvider.notifier).state =
|
|
RidingDiscipline.all;
|
|
ref.read(feedSearchQueryProvider.notifier).state = '';
|
|
_searchController.clear();
|
|
setState(() {
|
|
_isSearchExpanded = false;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStickyShareButton(BuildContext context) {
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final buttonBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
|
final buttonFg = isLight ? AppColors.onBrand : const Color(0xFF00272E);
|
|
|
|
return Container(
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: buttonBg,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: buttonBg.withValues(alpha: 0.3),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ElevatedButton(
|
|
key: const ValueKey('feed_share_session_button'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
foregroundColor: buttonFg,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
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.5,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.8,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color _getDisciplineColor(RidingDiscipline discipline, {bool isLight = false}) {
|
|
if (isLight) {
|
|
switch (discipline) {
|
|
case RidingDiscipline.canyonSport:
|
|
return AppColors.semanticWarningLight;
|
|
case RidingDiscipline.advDualSport:
|
|
return AppColors.semanticSuccessLight;
|
|
case RidingDiscipline.touring:
|
|
return AppColors.brandBlueStrong;
|
|
case RidingDiscipline.trackDay:
|
|
return AppColors.semanticDanger;
|
|
case RidingDiscipline.cruiser:
|
|
return AppColors.brandBlueStrong;
|
|
case RidingDiscipline.all:
|
|
return AppColors.brandBlueStrong;
|
|
}
|
|
}
|
|
switch (discipline) {
|
|
case RidingDiscipline.canyonSport:
|
|
return AppColors.hazardAmber;
|
|
case RidingDiscipline.advDualSport:
|
|
return AppColors.telemetryGreen;
|
|
case RidingDiscipline.touring:
|
|
return AppColors.electricCyan;
|
|
case RidingDiscipline.trackDay:
|
|
return AppColors.semanticDangerDark;
|
|
case RidingDiscipline.cruiser:
|
|
return AppColors.electricCyan;
|
|
case RidingDiscipline.all:
|
|
return AppColors.electricCyan;
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
|
|
void _showGpxExportSnackBar(
|
|
BuildContext context,
|
|
MotorSignalThemeColors colors,
|
|
Color accentColor,
|
|
SocialFeedItem item,
|
|
) {
|
|
final isLandscape =
|
|
MediaQuery.of(context).orientation == Orientation.landscape;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: colors.surfaceCardSecondary,
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: EdgeInsets.fromLTRB(
|
|
16,
|
|
0,
|
|
16,
|
|
isLandscape ? 8 : 72,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: colors.surfaceBorder),
|
|
),
|
|
content: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.download_done_rounded,
|
|
color: accentColor,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
'GPX track exported for ${item.rideTitle}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final secondaryTextColor =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
final accentColor =
|
|
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
|
final demoColor =
|
|
isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber;
|
|
final disciplineColor =
|
|
_getDisciplineColor(widget.item.discipline, isLight: isLight);
|
|
final bumpedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
|
final bumpedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E);
|
|
|
|
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: colors.surfaceCardPrimary,
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(color: colors.surfaceBorder),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colors.glowShadow,
|
|
blurRadius: 14,
|
|
offset: const Offset(0, 6),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Card Header: Author info, badge, discipline, 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: colors.surfaceCardSecondary,
|
|
child: Icon(
|
|
Icons.sports_motorsports_rounded,
|
|
color: accentColor,
|
|
size: 26,
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
right: 0,
|
|
child: Container(
|
|
width: 11,
|
|
height: 11,
|
|
decoration: BoxDecoration(
|
|
color: accentColor,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: colors.surfaceCardPrimary,
|
|
width: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Author handle, badge, discipline badge, club, timestamp
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
spacing: 6,
|
|
runSpacing: 4,
|
|
children: [
|
|
Text(
|
|
'@${item.authorHandle}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
if (item.isDemo) ...[
|
|
Container(
|
|
key: const ValueKey('feed_demo_pill'),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: demoColor.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(
|
|
color: demoColor.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
child: Text(
|
|
'DEMO SAMPLE',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 8.5,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.5,
|
|
color: demoColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (item.authorBadge != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: accentColor.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
item.authorBadge!,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.6,
|
|
color: accentColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (item.discipline != RidingDiscipline.all) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: disciplineColor.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(
|
|
color: disciplineColor.withValues(alpha: 0.35),
|
|
width: 0.8,
|
|
),
|
|
),
|
|
child: Text(
|
|
item.discipline.displayName.toUpperCase(),
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 8.5,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.5,
|
|
color: disciplineColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (item.clubName != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.clubName!,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 12,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${_formatRelativeTime(item.timestamp)} • ${item.locationName}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w500,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Overflow options menu
|
|
PopupMenuButton<String>(
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
color: secondaryTextColor,
|
|
size: 20,
|
|
),
|
|
color: colors.surfaceCardSecondary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: colors.surfaceBorder),
|
|
),
|
|
onSelected: (val) {
|
|
if (val == 'export_gpx') {
|
|
_showGpxExportSnackBar(context, colors, accentColor, item);
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: colors.surfaceCardSecondary,
|
|
content: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Action: $val',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: colors.textPrimary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
},
|
|
itemBuilder: (ctx) => [
|
|
PopupMenuItem(
|
|
value: 'copy_link',
|
|
child: Text(
|
|
'Copy Route Link',
|
|
style: TextStyle(color: colors.textPrimary, fontSize: 13),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'export_gpx',
|
|
child: Text(
|
|
'Export GPX File',
|
|
style: TextStyle(color: colors.textPrimary, fontSize: 13),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'report',
|
|
child: Text(
|
|
'Report Content',
|
|
style: TextStyle(color: colors.dangerRed, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Route Title & Motorcycle Tag
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.rideTitle,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: colors.surfaceBorder),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.sports_motorsports_rounded,
|
|
size: 14,
|
|
color: accentColor,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
item.bikeModel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
if (item.regionName != null) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
width: 3,
|
|
height: 3,
|
|
decoration: BoxDecoration(
|
|
color: secondaryTextColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
item.regionName!,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 11,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: colors.surfaceBorder),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_buildMetricCell(
|
|
context: context,
|
|
label: 'DISTANCE',
|
|
value: item.distanceMiles.toStringAsFixed(1),
|
|
unit: 'MI',
|
|
valueColor: colors.textPrimary,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildMetricCell(
|
|
context: context,
|
|
label: 'DURATION',
|
|
value: _formatDuration(item.durationSeconds),
|
|
unit: '',
|
|
valueColor: colors.textPrimary,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
_buildMetricCell(
|
|
context: context,
|
|
label: 'MAX LEAN',
|
|
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
|
|
unit: item.maxLeanSide,
|
|
valueColor: accentColor,
|
|
indicatorColor: accentColor,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_buildMetricCell(
|
|
context: context,
|
|
label: 'PACE',
|
|
value: item.paceFactor.split(' ').first,
|
|
unit: item.paceFactor.split(' ').length > 1
|
|
? item.paceFactor.split(' ').sublist(1).join(' ')
|
|
: '',
|
|
valueColor: accentColor,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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
|
|
? bumpedBg
|
|
: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isBumped
|
|
? bumpedBg
|
|
: colors.surfaceBorder,
|
|
),
|
|
),
|
|
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
|
|
? bumpedFg
|
|
: colors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colors.surfaceBorder),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.chat_bubble_outline_rounded,
|
|
size: 18,
|
|
color: accentColor,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'${item.comments.length}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
|
|
// GPX Export Button
|
|
InkWell(
|
|
key: ValueKey('gpx_export_button_${item.id}'),
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
_showGpxExportSnackBar(context, colors, accentColor, item);
|
|
},
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: colors.surfaceBorder),
|
|
),
|
|
child: Icon(
|
|
Icons.download_rounded,
|
|
size: 20,
|
|
color: colors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Telemetry Deep Dive Inspector Trigger
|
|
InkWell(
|
|
key: ValueKey('inspect_telemetry_btn_${item.id}'),
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
FeedTelemetryInspectorModal.show(context, item);
|
|
},
|
|
child: Container(
|
|
height: 44,
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardSecondary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: accentColor.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.bolt_rounded,
|
|
size: 18,
|
|
color: accentColor,
|
|
),
|
|
const 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: accentColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricCell({
|
|
required BuildContext context,
|
|
required String label,
|
|
required String value,
|
|
required String unit,
|
|
required Color valueColor,
|
|
Color? indicatorColor,
|
|
}) {
|
|
final colors = context.motorColors;
|
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
|
final secondaryTextColor =
|
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
|
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceCardPrimary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: colors.surfaceBorder, width: 0.8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.6,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
),
|
|
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: 15.5,
|
|
fontWeight: FontWeight.w800,
|
|
color: valueColor,
|
|
),
|
|
),
|
|
),
|
|
if (unit.isNotEmpty) ...[
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
unit,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: secondaryTextColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) {
|
|
final hasValidTrack = item.hasRecordedRoute;
|
|
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: hasValidTrack ? item.routePoints : const [],
|
|
maxLean: item.maxLeanAngleDeg,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (!hasValidTrack)
|
|
Positioned.fill(
|
|
child: Center(
|
|
child: Container(
|
|
key: ValueKey('feed_route_no_gps_state_${item.id}'),
|
|
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF101419).withValues(alpha: 0.88),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: const Color(0xFF2A323D),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: const [
|
|
Icon(
|
|
Icons.gps_off_rounded,
|
|
size: 14,
|
|
color: Color(0xFF94A3B8),
|
|
),
|
|
SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'NO GPS TRACK RECORDED',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.5,
|
|
color: Color(0xFFCBD5E1),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 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 (strictly requires verified GPS trace criteria)
|
|
if (item.hasVerifiedGps) ...[
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (item.hasVerifiedGps)
|
|
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.
|
|
/// Plots recorded [points] normalized to canvas bounds. Does not fabricate
|
|
/// synthetic curves when fewer than 2 points exist.
|
|
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);
|
|
}
|
|
|
|
if (points.length < 2) {
|
|
return;
|
|
}
|
|
|
|
// 2. Normalize recorded GPS points into canvas coordinates
|
|
double minLat = points.first.latitude;
|
|
double maxLat = points.first.latitude;
|
|
double minLng = points.first.longitude;
|
|
double maxLng = points.first.longitude;
|
|
|
|
for (final pt in points) {
|
|
if (pt.latitude < minLat) minLat = pt.latitude;
|
|
if (pt.latitude > maxLat) maxLat = pt.latitude;
|
|
if (pt.longitude < minLng) minLng = pt.longitude;
|
|
if (pt.longitude > maxLng) maxLng = pt.longitude;
|
|
}
|
|
|
|
const padX = 28.0;
|
|
const padY = 26.0;
|
|
final usableW = (w - padX * 2).clamp(1.0, w);
|
|
final usableH = (h - padY * 2).clamp(1.0, h);
|
|
final latSpan = (maxLat - minLat).abs();
|
|
final lngSpan = (maxLng - minLng).abs();
|
|
|
|
final mappedPoints = <Offset>[];
|
|
for (var i = 0; i < points.length; i++) {
|
|
final pt = points[i];
|
|
final normX = lngSpan < 1e-6
|
|
? (i / (points.length - 1))
|
|
: (pt.longitude - minLng) / lngSpan;
|
|
final normY = latSpan < 1e-6
|
|
? 0.5
|
|
: 1.0 - ((pt.latitude - minLat) / latSpan);
|
|
mappedPoints.add(
|
|
Offset(padX + normX * usableW, padY + normY * usableH),
|
|
);
|
|
}
|
|
|
|
final path = Path()..moveTo(mappedPoints.first.dx, mappedPoints.first.dy);
|
|
for (var i = 1; i < mappedPoints.length; i++) {
|
|
path.lineTo(mappedPoints[i].dx, mappedPoints[i].dy);
|
|
}
|
|
|
|
// Cyan glow blur
|
|
final glowPaint = Paint()
|
|
..color = AppColors.electricCyan.withValues(alpha: 0.25)
|
|
..strokeWidth = 8
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
canvas.drawPath(path, glowPaint);
|
|
|
|
// Active trail
|
|
final trailPaint = Paint()
|
|
..color = const Color(0xFFC3F5FF)
|
|
..strokeWidth = 3.5
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
canvas.drawPath(path, trailPaint);
|
|
|
|
final startOffset = mappedPoints.first;
|
|
final finishOffset = mappedPoints.last;
|
|
final apexOffset = mappedPoints[mappedPoints.length ~/ 2];
|
|
|
|
// Start marker with ring
|
|
final startPaint = Paint()..color = AppColors.electricCyan;
|
|
canvas.drawCircle(startOffset, 4.5, startPaint);
|
|
final ringPaint = Paint()
|
|
..color = AppColors.electricCyan.withValues(alpha: 0.6)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5;
|
|
canvas.drawCircle(startOffset, 8.0, ringPaint);
|
|
|
|
// Apex marker (only if maxLean > 0)
|
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
if (maxLean > 0) {
|
|
final apexPaint = Paint()..color = AppColors.electricCyan;
|
|
canvas.drawCircle(apexOffset, 4.0, apexPaint);
|
|
|
|
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(
|
|
(apexOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0),
|
|
(apexOffset.dy - 16).clamp(4.0, h - textPainter.height - 4.0),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Finish marker
|
|
final finishPaint = Paint()..color = const Color(0xFFBAC9CC);
|
|
canvas.drawCircle(finishOffset, 4.0, finishPaint);
|
|
|
|
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(
|
|
(finishOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0),
|
|
(finishOffset.dy + 8).clamp(4.0, h - textPainter.height - 4.0),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _FeedRoutePainter oldDelegate) {
|
|
return oldDelegate.maxLean != maxLean || oldDelegate.points != points;
|
|
}
|
|
}
|