243 lines
8.0 KiB
Dart
243 lines
8.0 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../services/auth/auth_providers.dart';
|
|
import '../../../services/clubs/club_providers.dart';
|
|
import '../../../services/clubs/club_service.dart';
|
|
|
|
/// State of active membership mutations (join, request, withdraw, accept, decline, leave).
|
|
@immutable
|
|
class ClubMembershipActionState {
|
|
final bool isSubmitting;
|
|
final String? error;
|
|
final String? success;
|
|
final String? activeClubId;
|
|
|
|
const ClubMembershipActionState({
|
|
this.isSubmitting = false,
|
|
this.error,
|
|
this.success,
|
|
this.activeClubId,
|
|
});
|
|
|
|
ClubMembershipActionState copyWith({
|
|
bool? isSubmitting,
|
|
String? error,
|
|
String? success,
|
|
String? activeClubId,
|
|
bool clearError = false,
|
|
bool clearSuccess = false,
|
|
}) {
|
|
return ClubMembershipActionState(
|
|
isSubmitting: isSubmitting ?? this.isSubmitting,
|
|
error: clearError ? null : (error ?? this.error),
|
|
success: clearSuccess ? null : (success ?? this.success),
|
|
activeClubId: activeClubId ?? this.activeClubId,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is ClubMembershipActionState &&
|
|
runtimeType == other.runtimeType &&
|
|
isSubmitting == other.isSubmitting &&
|
|
error == other.error &&
|
|
success == other.success &&
|
|
activeClubId == other.activeClubId;
|
|
|
|
@override
|
|
int get hashCode =>
|
|
isSubmitting.hashCode ^
|
|
error.hashCode ^
|
|
success.hashCode ^
|
|
activeClubId.hashCode;
|
|
}
|
|
|
|
/// Controller coordinating membership lifecycle operations.
|
|
/// Interacts with authoritative ClubService and synchronizes Riverpod streams.
|
|
class ClubMembershipController extends StateNotifier<ClubMembershipActionState> {
|
|
final Ref _ref;
|
|
final String clubId;
|
|
|
|
ClubMembershipController(this._ref, this.clubId)
|
|
: super(ClubMembershipActionState(activeClubId: clubId));
|
|
|
|
void clearMessages() {
|
|
state = state.copyWith(clearError: true, clearSuccess: true);
|
|
}
|
|
|
|
/// Direct self-join for open admission clubs.
|
|
Future<bool> joinOpenClub([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) => service.joinOpenClub(effectiveClubId, callerUid: uid),
|
|
successMessage: 'Successfully joined club!',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
_ref.invalidate(clubDetailProvider(effectiveClubId));
|
|
_ref.invalidate(myClubsProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Request admission for request-policy clubs.
|
|
Future<bool> requestToJoinClub([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) => service.requestToJoinClub(effectiveClubId, callerUid: uid),
|
|
successMessage: 'Join request submitted to club leadership.',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Withdraw an existing pending request.
|
|
Future<bool> withdrawJoinRequest([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) => service.withdrawJoinRequest(effectiveClubId, callerUid: uid),
|
|
successMessage: 'Join request withdrawn.',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Accept an incoming leadership invitation.
|
|
Future<bool> acceptInvitation([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) =>
|
|
service.respondToInvitation(effectiveClubId, accept: true, callerUid: uid),
|
|
successMessage: 'Invitation accepted! Welcome to the club.',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
_ref.invalidate(clubDetailProvider(effectiveClubId));
|
|
_ref.invalidate(myClubsProvider);
|
|
_ref.invalidate(myClubInvitesProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Decline an incoming leadership invitation.
|
|
Future<bool> declineInvitation([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) =>
|
|
service.respondToInvitation(effectiveClubId, accept: false, callerUid: uid),
|
|
successMessage: 'Invitation declined.',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
_ref.invalidate(myClubInvitesProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Voluntarily leave an active club membership.
|
|
/// Enforces invariant: Owner cannot leave.
|
|
Future<bool> leaveClub([String? targetClubId]) async {
|
|
final effectiveClubId = targetClubId ?? clubId;
|
|
return _performMutation(
|
|
clubId: effectiveClubId,
|
|
mutation: (service, uid) => service.leaveClub(effectiveClubId, callerUid: uid),
|
|
successMessage: 'You have left the club.',
|
|
onSuccessInvalidate: () {
|
|
_ref.invalidate(currentMemberProvider(effectiveClubId));
|
|
_ref.invalidate(clubDetailProvider(effectiveClubId));
|
|
_ref.invalidate(myClubsProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _performMutation({
|
|
required String clubId,
|
|
required Future<void> Function(ClubService service, String uid) mutation,
|
|
required String successMessage,
|
|
required VoidCallback onSuccessInvalidate,
|
|
}) async {
|
|
// Concurrency guard: reject duplicate or overlapping mutations in-flight
|
|
if (state.isSubmitting) {
|
|
return false;
|
|
}
|
|
|
|
final user = _ref.read(currentUserProvider);
|
|
if (user == null || user.isAnonymous) {
|
|
state = state.copyWith(
|
|
error: 'You must be signed in with a registered account to perform this action.',
|
|
clearSuccess: true,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
state = ClubMembershipActionState(
|
|
isSubmitting: true,
|
|
activeClubId: clubId,
|
|
);
|
|
|
|
try {
|
|
final service = _ref.read(clubServiceProvider);
|
|
await mutation(service, user.uid);
|
|
|
|
// Perform minimal, surgical invalidations for this exact lifecycle transition
|
|
onSuccessInvalidate();
|
|
|
|
state = ClubMembershipActionState(
|
|
isSubmitting: false,
|
|
success: successMessage,
|
|
activeClubId: clubId,
|
|
);
|
|
return true;
|
|
} on ClubOperationException catch (e) {
|
|
String message;
|
|
switch (e.code) {
|
|
case 'banned':
|
|
message = 'Membership is unavailable for this club.';
|
|
break;
|
|
case 'owner-cannot-leave':
|
|
message = 'Club owner cannot leave without transferring ownership first.';
|
|
break;
|
|
case 'policy-mismatch':
|
|
message = 'This club does not allow open joining. Please submit a request.';
|
|
break;
|
|
case 'not-found':
|
|
message = 'Club or membership record was not found.';
|
|
break;
|
|
case 'unavailable':
|
|
message = 'Club services are temporarily unavailable. Please try again later.';
|
|
break;
|
|
default:
|
|
message = e.message.isNotEmpty
|
|
? e.message
|
|
: 'Unable to update club membership right now. Please try again.';
|
|
break;
|
|
}
|
|
|
|
state = ClubMembershipActionState(
|
|
isSubmitting: false,
|
|
error: message,
|
|
activeClubId: clubId,
|
|
);
|
|
return false;
|
|
} catch (e) {
|
|
state = ClubMembershipActionState(
|
|
isSubmitting: false,
|
|
error: 'Unable to update club membership right now. Please try again.',
|
|
activeClubId: clubId,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Family provider for ClubMembershipController scoped per club.
|
|
final clubMembershipControllerProvider = StateNotifierProvider.autoDispose
|
|
.family<ClubMembershipController, ClubMembershipActionState, String>((ref, clubId) {
|
|
return ClubMembershipController(ref, clubId);
|
|
});
|