610 lines
18 KiB
Dart
610 lines
18 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;
|
|
}
|