MotorSignal-App/lib/services/social/social_feed_models.dart

295 lines
10 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
import '../map/map_provider_interface.dart';
DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
dynamic _dateTimeToFirestore(DateTime? dt) {
if (dt == null) return null;
return Timestamp.fromDate(dt);
}
/// Filter for the community social feed.
enum FeedFilter {
allActivity,
clubOnly,
nearbyPacks;
String get displayName {
switch (this) {
case FeedFilter.allActivity:
return 'All Activity';
case FeedFilter.clubOnly:
return 'Club Only';
case FeedFilter.nearbyPacks:
return 'Nearby Packs';
}
}
}
/// A comment on a community social feed post.
@immutable
class FeedPostComment {
final String id;
final String authorUid;
final String authorDisplayName;
final String? authorHandle;
final String? authorAvatarPreset;
final String content;
final DateTime createdAt;
const FeedPostComment({
required this.id,
required this.authorUid,
required this.authorDisplayName,
this.authorHandle,
this.authorAvatarPreset,
required this.content,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'authorUid': authorUid,
'authorDisplayName': authorDisplayName,
if (authorHandle != null) 'authorHandle': authorHandle,
if (authorAvatarPreset != null) 'authorAvatarPreset': authorAvatarPreset,
'content': content,
'createdAt': _dateTimeToFirestore(createdAt),
};
factory FeedPostComment.fromJson(Map<String, dynamic> json) {
return FeedPostComment(
id: json['id'] as String? ?? '',
authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String?,
authorAvatarPreset: json['authorAvatarPreset'] as String?,
content: json['content'] as String? ?? '',
createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(),
);
}
}
/// Visual snapshot type for a feed item.
enum FeedSnapshotType {
routeTrack,
scenicPhoto,
}
/// A community social feed item (Stitch SOC-01).
@immutable
class SocialFeedItem {
final String id;
final String authorUid;
final String authorDisplayName;
final String authorHandle;
final String? authorAvatarPreset;
final String? authorBadge; // e.g. 'PRO', 'ROAD CAPTAIN', 'FOUNDER'
final String? clubName; // e.g. 'Bay Area Twisties Club'
final String locationName; // e.g. "Alice's Restaurant", "Mulholland Hwy"
final DateTime timestamp;
final String rideTitle; // e.g. "Sunday Skyline & Pescadero Carve"
final String bikeModel; // e.g. "2024 Ducati Streetfighter V4"
// Inset Telemetry Metrics
final double distanceMiles;
final int durationSeconds;
final double maxLeanAngleDeg;
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK'
final String paceFactor; // e.g. '1.32x Spirited'
final int hazardCount; // e.g. 0
final int elevationFt; // e.g. +2140
// Visual Snapshot Module
final FeedSnapshotType snapshotType;
final List<AppLatLng> routePoints;
final String? photoUrl;
final String? photoCaption;
final bool isGpsVerified;
// Kudos & Reactions
final int fistBumpCount;
final List<String> fistBumpUserIds;
final List<FeedPostComment> comments;
const SocialFeedItem({
required this.id,
required this.authorUid,
required this.authorDisplayName,
required this.authorHandle,
this.authorAvatarPreset,
this.authorBadge,
this.clubName,
required this.locationName,
required this.timestamp,
required this.rideTitle,
required this.bikeModel,
required this.distanceMiles,
required this.durationSeconds,
required this.maxLeanAngleDeg,
this.maxLeanSide = 'RIGHT',
this.paceFactor = '1.2x Spirited',
this.hazardCount = 0,
this.elevationFt = 1200,
this.snapshotType = FeedSnapshotType.routeTrack,
this.routePoints = const [],
this.photoUrl,
this.photoCaption,
this.isGpsVerified = true,
this.fistBumpCount = 0,
this.fistBumpUserIds = const [],
this.comments = const [],
});
bool isFistBumpedBy(String? uid) {
if (uid == null) return false;
return fistBumpUserIds.contains(uid);
}
SocialFeedItem copyWith({
String? id,
String? authorUid,
String? authorDisplayName,
String? authorHandle,
String? authorAvatarPreset,
String? authorBadge,
String? clubName,
String? locationName,
DateTime? timestamp,
String? rideTitle,
String? bikeModel,
double? distanceMiles,
int? durationSeconds,
double? maxLeanAngleDeg,
String? maxLeanSide,
String? paceFactor,
int? hazardCount,
int? elevationFt,
FeedSnapshotType? snapshotType,
List<AppLatLng>? routePoints,
String? photoUrl,
String? photoCaption,
bool? isGpsVerified,
int? fistBumpCount,
List<String>? fistBumpUserIds,
List<FeedPostComment>? comments,
}) {
return SocialFeedItem(
id: id ?? this.id,
authorUid: authorUid ?? this.authorUid,
authorDisplayName: authorDisplayName ?? this.authorDisplayName,
authorHandle: authorHandle ?? this.authorHandle,
authorAvatarPreset: authorAvatarPreset ?? this.authorAvatarPreset,
authorBadge: authorBadge ?? this.authorBadge,
clubName: clubName ?? this.clubName,
locationName: locationName ?? this.locationName,
timestamp: timestamp ?? this.timestamp,
rideTitle: rideTitle ?? this.rideTitle,
bikeModel: bikeModel ?? this.bikeModel,
distanceMiles: distanceMiles ?? this.distanceMiles,
durationSeconds: durationSeconds ?? this.durationSeconds,
maxLeanAngleDeg: maxLeanAngleDeg ?? this.maxLeanAngleDeg,
maxLeanSide: maxLeanSide ?? this.maxLeanSide,
paceFactor: paceFactor ?? this.paceFactor,
hazardCount: hazardCount ?? this.hazardCount,
elevationFt: elevationFt ?? this.elevationFt,
snapshotType: snapshotType ?? this.snapshotType,
routePoints: routePoints ?? this.routePoints,
photoUrl: photoUrl ?? this.photoUrl,
photoCaption: photoCaption ?? this.photoCaption,
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
fistBumpCount: fistBumpCount ?? this.fistBumpCount,
fistBumpUserIds: fistBumpUserIds ?? this.fistBumpUserIds,
comments: comments ?? this.comments,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'authorUid': authorUid,
'authorDisplayName': authorDisplayName,
'authorHandle': authorHandle,
if (authorAvatarPreset != null) 'authorAvatarPreset': authorAvatarPreset,
if (authorBadge != null) 'authorBadge': authorBadge,
if (clubName != null) 'clubName': clubName,
'locationName': locationName,
'timestamp': _dateTimeToFirestore(timestamp),
'rideTitle': rideTitle,
'bikeModel': bikeModel,
'distanceMiles': distanceMiles,
'durationSeconds': durationSeconds,
'maxLeanAngleDeg': maxLeanAngleDeg,
'maxLeanSide': maxLeanSide,
'paceFactor': paceFactor,
'hazardCount': hazardCount,
'elevationFt': elevationFt,
'snapshotType': snapshotType.name,
'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(),
if (photoUrl != null) 'photoUrl': photoUrl,
if (photoCaption != null) 'photoCaption': photoCaption,
'isGpsVerified': isGpsVerified,
'fistBumpCount': fistBumpCount,
'fistBumpUserIds': fistBumpUserIds,
'comments': comments.map((c) => c.toJson()).toList(),
};
factory SocialFeedItem.fromJson(Map<String, dynamic> json) {
final routeList = json['routePoints'] as List<dynamic>? ?? [];
final points = <AppLatLng>[];
for (final item in routeList) {
if (item is Map) {
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat != null && lng != null) {
points.add(AppLatLng(lat, lng));
}
}
}
final commentList = json['comments'] as List<dynamic>? ?? [];
final comments = commentList
.whereType<Map<String, dynamic>>()
.map((c) => FeedPostComment.fromJson(c))
.toList();
return SocialFeedItem(
id: json['id'] as String? ?? '',
authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String? ?? 'rider',
authorAvatarPreset: json['authorAvatarPreset'] as String?,
authorBadge: json['authorBadge'] as String?,
clubName: json['clubName'] as String?,
locationName: json['locationName'] as String? ?? 'Canyon Route',
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0,
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
paceFactor: json['paceFactor'] as String? ?? '1.0x',
hazardCount: (json['hazardCount'] as num?)?.toInt() ?? 0,
elevationFt: (json['elevationFt'] as num?)?.toInt() ?? 0,
snapshotType: json['snapshotType'] == 'scenicPhoto'
? FeedSnapshotType.scenicPhoto
: FeedSnapshotType.routeTrack,
routePoints: points,
photoUrl: json['photoUrl'] as String?,
photoCaption: json['photoCaption'] as String?,
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
comments: comments,
);
}
}