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 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 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? ?? {}; return Club.fromJson(data, idOverride: doc.id); } factory Club.fromJson(Map 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 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 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? ?? {}; return ClubMember.fromJson(data, uidOverride: doc.id); } factory ClubMember.fromJson(Map 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 toFirestore() => { 'uid': uid, 'clubId': clubId, 'bannedByUid': bannedByUid, 'bannedAt': _dateTimeToFirestore(bannedAt), if (reason != null) 'reason': reason, }; Map 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? ?? {}; return ClubBan.fromJson(data, uidOverride: doc.id); } factory ClubBan.fromJson(Map 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 toFirestore() => { 'slug': slug, 'clubId': clubId, 'ownerUid': ownerUid, 'createdAt': _dateTimeToFirestore(createdAt), }; Map toJson() => { 'slug': slug, 'clubId': clubId, 'ownerUid': ownerUid, 'createdAt': createdAt.toIso8601String(), }; factory ClubHandle.fromJson(Map 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; }