MotorSignal-App/lib/services/clubs/club_models.dart

715 lines
22 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
/// Visibility setting governing discoverability and access to a Club.
enum ClubVisibility {
public,
unlisted,
private;
String get label {
switch (this) {
case ClubVisibility.public:
return 'Public';
case ClubVisibility.unlisted:
return 'Unlisted';
case ClubVisibility.private:
return 'Private';
}
}
static ClubVisibility fromString(String? value) {
switch (value) {
case 'unlisted':
return ClubVisibility.unlisted;
case 'private':
return ClubVisibility.private;
case 'public':
default:
return ClubVisibility.public;
}
}
}
/// Admission policy governing onboarding into a Club.
enum ClubAdmissionPolicy {
open,
request,
inviteOnly;
String get label {
switch (this) {
case ClubAdmissionPolicy.open:
return 'Open to Join';
case ClubAdmissionPolicy.request:
return 'Request to Join';
case ClubAdmissionPolicy.inviteOnly:
return 'Invite Only';
}
}
static ClubAdmissionPolicy fromString(String? value) {
switch (value) {
case 'open':
return ClubAdmissionPolicy.open;
case 'inviteOnly':
return ClubAdmissionPolicy.inviteOnly;
case 'request':
default:
return ClubAdmissionPolicy.request;
}
}
}
/// Security permission role within a Club.
enum ClubRole {
owner,
admin,
member;
String get label {
switch (this) {
case ClubRole.owner:
return 'Owner';
case ClubRole.admin:
return 'Admin';
case ClubRole.member:
return 'Member';
}
}
bool isAtLeast(ClubRole requiredRole) {
if (this == ClubRole.owner) return true;
if (this == ClubRole.admin) {
return requiredRole == ClubRole.admin || requiredRole == ClubRole.member;
}
return requiredRole == ClubRole.member;
}
static ClubRole fromString(String? value) {
switch (value) {
case 'owner':
return ClubRole.owner;
case 'admin':
return ClubRole.admin;
case 'member':
default:
return ClubRole.member;
}
}
}
/// Lifecycle status of an individual Club member.
enum ClubMemberStatus {
invited,
requested,
active,
declined,
left,
removed;
String get label {
switch (this) {
case ClubMemberStatus.invited:
return 'Invited';
case ClubMemberStatus.requested:
return 'Requested';
case ClubMemberStatus.active:
return 'Active';
case ClubMemberStatus.declined:
return 'Declined';
case ClubMemberStatus.left:
return 'Left';
case ClubMemberStatus.removed:
return 'Removed';
}
}
bool get isActive => this == ClubMemberStatus.active;
bool get isPending => this == ClubMemberStatus.invited || this == ClubMemberStatus.requested;
bool get isHistorical => this == ClubMemberStatus.declined || this == ClubMemberStatus.left || this == ClubMemberStatus.removed;
static ClubMemberStatus fromString(String? value) {
switch (value) {
case 'invited':
return ClubMemberStatus.invited;
case 'requested':
return ClubMemberStatus.requested;
case 'active':
return ClubMemberStatus.active;
case 'declined':
return ClubMemberStatus.declined;
case 'left':
return ClubMemberStatus.left;
case 'removed':
return ClubMemberStatus.removed;
default:
return ClubMemberStatus.declined;
}
}
}
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);
}
/// Core MotorSignal Club model.
@immutable
class Club {
final String id;
final String name;
final String slug;
final String description;
final ClubVisibility visibility;
final ClubAdmissionPolicy admissionPolicy;
final String ownerUid;
final int memberCount;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? archivedAt;
final String? logoUrl;
final String? bannerUrl;
final String? homeRegion;
final String? motorcycleFocus;
const Club({
required this.id,
required this.name,
required this.slug,
this.description = '',
this.visibility = ClubVisibility.public,
this.admissionPolicy = ClubAdmissionPolicy.request,
required this.ownerUid,
this.memberCount = 1,
required this.createdAt,
required this.updatedAt,
this.archivedAt,
this.logoUrl,
this.bannerUrl,
this.homeRegion,
this.motorcycleFocus,
});
bool get isArchived => archivedAt != null;
Map<String, dynamic> toFirestore() => {
'id': id,
'name': name,
'slug': slug,
'description': description,
'visibility': visibility.name,
'admissionPolicy': admissionPolicy.name,
'ownerUid': ownerUid,
'memberCount': memberCount,
'createdAt': _dateTimeToFirestore(createdAt),
'updatedAt': _dateTimeToFirestore(updatedAt),
// Keep the field present so Firestore rules can distinguish active
// clubs from legacy documents without a missing-field rule error.
'archivedAt': _dateTimeToFirestore(archivedAt),
if (logoUrl != null) 'logoUrl': logoUrl,
if (bannerUrl != null) 'bannerUrl': bannerUrl,
if (homeRegion != null) 'homeRegion': homeRegion,
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
};
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'slug': slug,
'description': description,
'visibility': visibility.name,
'admissionPolicy': admissionPolicy.name,
'ownerUid': ownerUid,
'memberCount': memberCount,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
if (archivedAt != null) 'archivedAt': archivedAt!.toIso8601String(),
if (logoUrl != null) 'logoUrl': logoUrl,
if (bannerUrl != null) 'bannerUrl': bannerUrl,
if (homeRegion != null) 'homeRegion': homeRegion,
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
};
factory Club.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return Club.fromJson(data, idOverride: doc.id);
}
factory Club.fromJson(Map<String, dynamic> json, {String? idOverride}) {
final created = _parseDateTime(json['createdAt']) ?? DateTime.now();
final updated = _parseDateTime(json['updatedAt']) ?? created;
final archived = _parseDateTime(json['archivedAt']);
return Club(
id: idOverride ?? json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
slug: (json['slug'] as String? ?? '').toLowerCase().trim(),
description: json['description'] as String? ?? '',
visibility: ClubVisibility.fromString(json['visibility'] as String?),
admissionPolicy: ClubAdmissionPolicy.fromString(json['admissionPolicy'] as String?),
ownerUid: json['ownerUid'] as String? ?? '',
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
createdAt: created,
updatedAt: updated,
archivedAt: archived,
logoUrl: json['logoUrl'] as String?,
bannerUrl: json['bannerUrl'] as String?,
homeRegion: json['homeRegion'] as String?,
motorcycleFocus: json['motorcycleFocus'] as String?,
);
}
Club copyWith({
String? id,
String? name,
String? slug,
String? description,
ClubVisibility? visibility,
ClubAdmissionPolicy? admissionPolicy,
String? ownerUid,
int? memberCount,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? archivedAt,
String? logoUrl,
String? bannerUrl,
String? homeRegion,
String? motorcycleFocus,
bool clearArchivedAt = false,
}) {
return Club(
id: id ?? this.id,
name: name ?? this.name,
slug: slug ?? this.slug,
description: description ?? this.description,
visibility: visibility ?? this.visibility,
admissionPolicy: admissionPolicy ?? this.admissionPolicy,
ownerUid: ownerUid ?? this.ownerUid,
memberCount: memberCount ?? this.memberCount,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
logoUrl: logoUrl ?? this.logoUrl,
bannerUrl: bannerUrl ?? this.bannerUrl,
homeRegion: homeRegion ?? this.homeRegion,
motorcycleFocus: motorcycleFocus ?? this.motorcycleFocus,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Club &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
slug == other.slug &&
description == other.description &&
visibility == other.visibility &&
admissionPolicy == other.admissionPolicy &&
ownerUid == other.ownerUid &&
memberCount == other.memberCount &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
archivedAt == other.archivedAt &&
logoUrl == other.logoUrl &&
bannerUrl == other.bannerUrl &&
homeRegion == other.homeRegion &&
motorcycleFocus == other.motorcycleFocus;
@override
int get hashCode =>
id.hashCode ^
name.hashCode ^
slug.hashCode ^
description.hashCode ^
visibility.hashCode ^
admissionPolicy.hashCode ^
ownerUid.hashCode ^
memberCount.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
archivedAt.hashCode ^
logoUrl.hashCode ^
bannerUrl.hashCode ^
homeRegion.hashCode ^
motorcycleFocus.hashCode;
}
/// Lean ClubMember document maintaining organizational state.
/// Canonical rider identity remains in public_profiles/{uid}.
@immutable
class ClubMember {
final String uid;
final String clubId;
final ClubMemberStatus status;
final ClubRole role;
final String? displayRank;
final DateTime? joinedAt;
final DateTime? requestedAt;
final String? invitedByUid;
final DateTime updatedAt;
final DateTime? removedAt;
const ClubMember({
required this.uid,
required this.clubId,
required this.status,
required this.role,
this.displayRank,
this.joinedAt,
this.requestedAt,
this.invitedByUid,
required this.updatedAt,
this.removedAt,
});
bool get isActive => status == ClubMemberStatus.active;
bool get isOwner => role == ClubRole.owner && isActive;
bool get isAdmin => (role == ClubRole.admin || role == ClubRole.owner) && isActive;
Map<String, dynamic> toFirestore() => {
'uid': uid,
'clubId': clubId,
'status': status.name,
'role': role.name,
if (displayRank != null) 'displayRank': displayRank,
if (joinedAt != null) 'joinedAt': _dateTimeToFirestore(joinedAt),
if (requestedAt != null) 'requestedAt': _dateTimeToFirestore(requestedAt),
if (invitedByUid != null) 'invitedByUid': invitedByUid,
'updatedAt': _dateTimeToFirestore(updatedAt),
if (removedAt != null) 'removedAt': _dateTimeToFirestore(removedAt),
};
Map<String, dynamic> toJson() => {
'uid': uid,
'clubId': clubId,
'status': status.name,
'role': role.name,
if (displayRank != null) 'displayRank': displayRank,
if (joinedAt != null) 'joinedAt': joinedAt!.toIso8601String(),
if (requestedAt != null) 'requestedAt': requestedAt!.toIso8601String(),
if (invitedByUid != null) 'invitedByUid': invitedByUid,
'updatedAt': updatedAt.toIso8601String(),
if (removedAt != null) 'removedAt': removedAt!.toIso8601String(),
};
factory ClubMember.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return ClubMember.fromJson(data, uidOverride: doc.id);
}
factory ClubMember.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
final updated = _parseDateTime(json['updatedAt']) ?? DateTime.now();
return ClubMember(
uid: uidOverride ?? json['uid'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
status: ClubMemberStatus.fromString(json['status'] as String?),
role: ClubRole.fromString(json['role'] as String?),
displayRank: json['displayRank'] as String?,
joinedAt: _parseDateTime(json['joinedAt']),
requestedAt: _parseDateTime(json['requestedAt']),
invitedByUid: json['invitedByUid'] as String?,
updatedAt: updated,
removedAt: _parseDateTime(json['removedAt']),
);
}
ClubMember copyWith({
String? uid,
String? clubId,
ClubMemberStatus? status,
ClubRole? role,
String? displayRank,
DateTime? joinedAt,
DateTime? requestedAt,
String? invitedByUid,
DateTime? updatedAt,
DateTime? removedAt,
bool clearDisplayRank = false,
}) {
return ClubMember(
uid: uid ?? this.uid,
clubId: clubId ?? this.clubId,
status: status ?? this.status,
role: role ?? this.role,
displayRank: clearDisplayRank ? null : (displayRank ?? this.displayRank),
joinedAt: joinedAt ?? this.joinedAt,
requestedAt: requestedAt ?? this.requestedAt,
invitedByUid: invitedByUid ?? this.invitedByUid,
updatedAt: updatedAt ?? this.updatedAt,
removedAt: removedAt ?? this.removedAt,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubMember &&
runtimeType == other.runtimeType &&
uid == other.uid &&
clubId == other.clubId &&
status == other.status &&
role == other.role &&
displayRank == other.displayRank &&
joinedAt == other.joinedAt &&
requestedAt == other.requestedAt &&
invitedByUid == other.invitedByUid &&
updatedAt == other.updatedAt &&
removedAt == other.removedAt;
@override
int get hashCode =>
uid.hashCode ^
clubId.hashCode ^
status.hashCode ^
role.hashCode ^
displayRank.hashCode ^
joinedAt.hashCode ^
requestedAt.hashCode ^
invitedByUid.hashCode ^
updatedAt.hashCode ^
removedAt.hashCode;
}
/// Administrative ban document stored at clubs/{clubId}/bans/{uid}.
/// Private to club leadership; never exposed on regular member roster.
@immutable
class ClubBan {
final String uid;
final String clubId;
final String bannedByUid;
final DateTime bannedAt;
final String? reason;
const ClubBan({
required this.uid,
required this.clubId,
required this.bannedByUid,
required this.bannedAt,
this.reason,
});
Map<String, dynamic> toFirestore() => {
'uid': uid,
'clubId': clubId,
'bannedByUid': bannedByUid,
'bannedAt': _dateTimeToFirestore(bannedAt),
if (reason != null) 'reason': reason,
};
Map<String, dynamic> toJson() => {
'uid': uid,
'clubId': clubId,
'bannedByUid': bannedByUid,
'bannedAt': bannedAt.toIso8601String(),
if (reason != null) 'reason': reason,
};
factory ClubBan.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return ClubBan.fromJson(data, uidOverride: doc.id);
}
factory ClubBan.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
return ClubBan(
uid: uidOverride ?? json['uid'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
bannedByUid: json['bannedByUid'] as String? ?? '',
bannedAt: _parseDateTime(json['bannedAt']) ?? DateTime.now(),
reason: json['reason'] as String?,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubBan &&
runtimeType == other.runtimeType &&
uid == other.uid &&
clubId == other.clubId &&
bannedByUid == other.bannedByUid &&
bannedAt == other.bannedAt &&
reason == other.reason;
@override
int get hashCode =>
uid.hashCode ^
clubId.hashCode ^
bannedByUid.hashCode ^
bannedAt.hashCode ^
reason.hashCode;
}
/// Global slug reservation document at club_handles/{slug}.
@immutable
class ClubHandle {
final String slug;
final String clubId;
final String ownerUid;
final DateTime createdAt;
const ClubHandle({
required this.slug,
required this.clubId,
required this.ownerUid,
required this.createdAt,
});
Map<String, dynamic> toFirestore() => {
'slug': slug,
'clubId': clubId,
'ownerUid': ownerUid,
'createdAt': _dateTimeToFirestore(createdAt),
};
Map<String, dynamic> toJson() => {
'slug': slug,
'clubId': clubId,
'ownerUid': ownerUid,
'createdAt': createdAt.toIso8601String(),
};
factory ClubHandle.fromJson(Map<String, dynamic> json, {String? slugOverride}) {
return ClubHandle(
slug: slugOverride ?? json['slug'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
ownerUid: json['ownerUid'] as String? ?? '',
createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubHandle &&
runtimeType == other.runtimeType &&
slug == other.slug &&
clubId == other.clubId &&
ownerUid == other.ownerUid &&
createdAt == other.createdAt;
@override
int get hashCode =>
slug.hashCode ^
clubId.hashCode ^
ownerUid.hashCode ^
createdAt.hashCode;
}
/// Minimized Club GroupRide discovery model returned by the authenticated discovery endpoint.
/// Represents pre-join browse card data without exposing sensitive participant access lists,
/// exact GPS coordinates, or full route geometry.
@immutable
class ClubRideDiscovery {
final String rideId;
final String clubId;
final String title;
final String? description;
final String? organizerDisplayName;
final String? organizerHandle;
final DateTime scheduledStartAt;
final DateTime? departureAt;
final String joinPolicy;
final int participantCount;
final int? participantLimit;
final String status;
final String? meetingLocationLabel;
final String? routeTitle;
final int? routeDistanceMeters;
final String? paceClassification;
const ClubRideDiscovery({
required this.rideId,
required this.clubId,
required this.title,
this.description,
this.organizerDisplayName,
this.organizerHandle,
required this.scheduledStartAt,
this.departureAt,
this.joinPolicy = 'inviteOnly',
this.participantCount = 1,
this.participantLimit,
this.status = 'scheduled',
this.meetingLocationLabel,
this.routeTitle,
this.routeDistanceMeters,
this.paceClassification,
});
factory ClubRideDiscovery.fromJson(Map<String, dynamic> json) {
return ClubRideDiscovery(
rideId: json['rideId'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
title: json['title'] as String? ?? 'Untitled Ride',
description: json['description'] as String?,
organizerDisplayName: json['organizerDisplayName'] as String?,
organizerHandle: json['organizerHandle'] as String?,
scheduledStartAt: _parseDateTime(json['scheduledStartAt']) ?? DateTime.now(),
departureAt: _parseDateTime(json['departureAt']),
joinPolicy: json['joinPolicy'] as String? ?? 'inviteOnly',
participantCount: (json['participantCount'] as num?)?.toInt() ?? 1,
participantLimit: (json['participantLimit'] as num?)?.toInt(),
status: json['status'] as String? ?? 'scheduled',
meetingLocationLabel: json['meetingLocationLabel'] as String?,
routeTitle: json['routeTitle'] as String?,
routeDistanceMeters: (json['routeDistanceMeters'] as num?)?.toInt(),
paceClassification: json['paceClassification'] as String?,
);
}
Map<String, dynamic> toJson() => {
'rideId': rideId,
'clubId': clubId,
'title': title,
if (description != null) 'description': description,
if (organizerDisplayName != null) 'organizerDisplayName': organizerDisplayName,
if (organizerHandle != null) 'organizerHandle': organizerHandle,
'scheduledStartAt': scheduledStartAt.toIso8601String(),
if (departureAt != null) 'departureAt': departureAt!.toIso8601String(),
'joinPolicy': joinPolicy,
'participantCount': participantCount,
if (participantLimit != null) 'participantLimit': participantLimit,
'status': status,
if (meetingLocationLabel != null) 'meetingLocationLabel': meetingLocationLabel,
if (routeTitle != null) 'routeTitle': routeTitle,
if (routeDistanceMeters != null) 'routeDistanceMeters': routeDistanceMeters,
if (paceClassification != null) 'paceClassification': paceClassification,
};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubRideDiscovery &&
runtimeType == other.runtimeType &&
rideId == other.rideId &&
clubId == other.clubId &&
title == other.title &&
scheduledStartAt == other.scheduledStartAt &&
joinPolicy == other.joinPolicy &&
participantCount == other.participantCount &&
status == other.status;
@override
int get hashCode =>
rideId.hashCode ^
clubId.hashCode ^
title.hashCode ^
scheduledStartAt.hashCode ^
joinPolicy.hashCode ^
participantCount.hashCode ^
status.hashCode;
}