import "package:bus_running_record/provider/supabase_state.dart"; import "package:bus_running_record/constants.dart"; import "package:flutter/foundation.dart"; import "package:supabase_flutter/supabase_flutter.dart"; class OrganizationSummary { const OrganizationSummary({ required this.id, required this.name, required this.slug, required this.iconUrl, required this.role, }); final String id; final String name; final String slug; final String? iconUrl; final String role; factory OrganizationSummary.fromApi(Map map) { final org = (map["organization"] as Map?)?.cast() ?? const {}; final iconUrlRaw = (org["icon_url"] ?? "").toString().trim(); return OrganizationSummary( id: (org["id"] ?? "").toString(), name: (org["name"] ?? "").toString(), slug: (org["slug"] ?? "").toString(), iconUrl: iconUrlRaw.isEmpty ? null : iconUrlRaw, role: (map["role"] ?? "member").toString(), ); } } class ChannelSummary { const ChannelSummary({ required this.id, required this.organizationId, required this.name, required this.description, required this.slug, required this.type, required this.isPrivate, required this.position, }); final String id; final String organizationId; final String name; final String description; final String slug; final String type; final bool isPrivate; final int position; factory ChannelSummary.fromApi(Map map) { final description = (map["description"] ?? map["topic"] ?? "").toString(); return ChannelSummary( id: (map["id"] ?? "").toString(), organizationId: (map["organization_id"] ?? "").toString(), name: (map["name"] ?? "").toString(), description: description, slug: (map["slug"] ?? "").toString(), type: (map["type"] ?? "text").toString(), isPrivate: map["is_private"] == true, position: (map["position"] as num?)?.toInt() ?? 0, ); } } class MessageSummary { const MessageSummary({ required this.id, required this.channelId, required this.authorUserId, required this.content, required this.createdAt, }); final String id; final String channelId; final String authorUserId; final String content; final DateTime? createdAt; factory MessageSummary.fromApi(Map map) { final createdAtRaw = (map["created_at"] ?? "").toString(); return MessageSummary( id: (map["id"] ?? "").toString(), channelId: (map["channel_id"] ?? "").toString(), authorUserId: (map["author_user_id"] ?? "").toString(), content: (map["content"] ?? "").toString(), createdAt: DateTime.tryParse(createdAtRaw), ); } } class CollaborationProvider extends ChangeNotifier { CollaborationProvider(this._supabaseProvider) { _supabaseProvider.addListener(_handleSupabaseStateChange); } final SupabaseProvider _supabaseProvider; bool _initialized = false; bool _isLoadingOrganizations = false; String? _errorMessage; List _organizations = const []; final Map> _channelsByOrganization = {}; String? _selectedOrganizationId; String? _selectedChannelId; bool get isLoadingOrganizations => _isLoadingOrganizations; String? get errorMessage => _errorMessage; List get organizations => _organizations; String? get selectedOrganizationId => _selectedOrganizationId; String? get selectedChannelId => _selectedChannelId; List channelsForOrganization(String orgId) => _channelsByOrganization[orgId] ?? const []; Future initialize() async { if (_initialized) return; _initialized = true; if (!_supabaseProvider.isAuthenticated) return; await refreshOrganizations(); } Future refreshOrganizations() async { _isLoadingOrganizations = true; _errorMessage = null; notifyListeners(); final previousOrgId = _selectedOrganizationId; final previousChannelId = _selectedChannelId; try { final response = await _invokeAuthedFunction("org-list", body: {}); final payload = _asMap(response.data); final orgRows = _asList(payload["organizations"]); _organizations = orgRows .map((row) => OrganizationSummary.fromApi(_asMap(row))) .where((org) => org.id.isNotEmpty) .toList(); if (_organizations.isEmpty) { _selectedOrganizationId = null; _selectedChannelId = null; _channelsByOrganization.clear(); } else { final orgStillExists = _organizations.any((o) => o.id == previousOrgId); _selectedOrganizationId = orgStillExists ? previousOrgId : _organizations.first.id; await _loadChannelsForOrganization( _selectedOrganizationId!, preferredChannelId: previousChannelId, ); } } catch (error, stackTrace) { _logError("refreshOrganizations/org-list", error, stackTrace); _errorMessage = error.toString(); } finally { _isLoadingOrganizations = false; notifyListeners(); } } void _handleSupabaseStateChange() { if (!_initialized) return; if (!_supabaseProvider.isAuthenticated) { _organizations = const []; _channelsByOrganization.clear(); _selectedOrganizationId = null; _selectedChannelId = null; _errorMessage = null; notifyListeners(); return; } refreshOrganizations(); } Future createOrganization(String name) async { try { final response = await _invokeAuthedFunction( "org-create", body: {"name": name}, ); final createdOrg = _asMap(_asMap(response.data)["organization"]); final orgId = (createdOrg["id"] ?? "").toString(); if (orgId.isNotEmpty) { try { await _invokeAuthedFunction( "channel-create", body: { "organization_id": orgId, "name": "general", "slug": "general", "type": "text", "position": 0, "is_private": false, }, ); } catch (error, stackTrace) { _logError("createOrganization/channel-create", error, stackTrace); // Best effort; org creation is the critical action. } } await refreshOrganizations(); if (orgId.isNotEmpty) { _selectedOrganizationId = orgId; notifyListeners(); } } catch (error, stackTrace) { _logError("createOrganization/org-create", error, stackTrace); _errorMessage = error.toString(); notifyListeners(); } } Future updateOrganization({ required String organizationId, String? name, String? iconUrl, }) async { final trimmedName = name?.trim(); try { final body = {"organization_id": organizationId}; if (trimmedName != null && trimmedName.isNotEmpty) { body["name"] = trimmedName; } if (iconUrl != null) { body["icon_url"] = iconUrl; } await _invokeAuthedFunction("org-update", body: body); await refreshOrganizations(); _selectedOrganizationId = organizationId; notifyListeners(); } catch (error, stackTrace) { _logError("updateOrganization/org-update", error, stackTrace); _errorMessage = error.toString(); notifyListeners(); rethrow; } } Future uploadOrganizationIcon({ required String organizationId, required Uint8List bytes, required String contentType, String extension = "png", }) async { final ext = extension.toLowerCase(); final safeExtension = RegExp(r"^[a-z0-9]+$").hasMatch(ext) ? ext : "png"; final path = "$organizationId/${DateTime.now().millisecondsSinceEpoch}.$safeExtension"; final client = _supabaseProvider.client; try { await client.storage .from("organization-icons") .uploadBinary( path, bytes, fileOptions: FileOptions(upsert: true, contentType: contentType), ); var publicUrl = client.storage .from("organization-icons") .getPublicUrl(path); if (Uri.tryParse(publicUrl)?.hasScheme != true) { publicUrl = "$kSupabaseEndpoint/storage/v1/object/public/organization-icons/$path"; } await updateOrganization( organizationId: organizationId, iconUrl: publicUrl, ); } catch (error, stackTrace) { _logError("uploadOrganizationIcon/storage-upload", error, stackTrace); _errorMessage = error.toString(); notifyListeners(); rethrow; } } Future createChannel({ required String organizationId, required String name, String description = "", String type = "text", bool isPrivate = false, }) async { try { await _invokeAuthedFunction( "channel-create", body: { "organization_id": organizationId, "name": name, "description": description, "type": type, "is_private": isPrivate, }, ); await _loadChannelsForOrganization(organizationId); notifyListeners(); } catch (error, stackTrace) { _logError("createChannel/channel-create", error, stackTrace); _errorMessage = error.toString(); notifyListeners(); rethrow; } } Future deleteChannel({ required String organizationId, required String channelId, }) async { try { await _invokeAuthedFunction( "channel-delete", body: {"channel_id": channelId}, ); await _loadChannelsForOrganization(organizationId); notifyListeners(); } catch (error, stackTrace) { _logError("deleteChannel/channel-delete", error, stackTrace); _errorMessage = error.toString(); notifyListeners(); rethrow; } } Future> listMessages({ required String channelId, int limit = 50, }) async { final response = await _invokeAuthedFunction( "message-list", body: {"channel_id": channelId, "limit": limit}, ); final payload = _asMap(response.data); final rows = _asList(payload["messages"]); return rows .map((row) => MessageSummary.fromApi(_asMap(row))) .where((message) => message.id.isNotEmpty) .toList(); } Future sendMessage({ required String channelId, required String content, }) async { await _invokeAuthedFunction( "message-send", body: {"channel_id": channelId, "content": content}, ); } Future createInvite({ required String organizationId, int maxUses = 1, int expiresInDays = 7, }) async { final response = await _invokeAuthedFunction( "org-invite-create", body: { "organization_id": organizationId, "max_uses": maxUses, "expires_in_days": expiresInDays, }, ); final payload = _asMap(response.data); final invite = _asMap(payload["invite"]); final token = (invite["token"] ?? "").toString(); if (token.isEmpty) { throw StateError("Invite token missing from response."); } return token; } Future acceptInviteToken(String token) async { final response = await _invokeAuthedFunction( "org-invite-accept", body: {"token": token}, ); final payload = _asMap(response.data); final organizationId = (payload["organization_id"] ?? "").toString(); if (organizationId.isEmpty) { throw StateError("Invite did not return organization_id."); } await refreshOrganizations(); _selectedOrganizationId = organizationId; await _loadChannelsForOrganization(organizationId); notifyListeners(); return organizationId; } Future selectOrganization(String organizationId) async { if (_selectedOrganizationId == organizationId) return; _selectedOrganizationId = organizationId; _selectedChannelId = null; notifyListeners(); await _loadChannelsForOrganization(organizationId); notifyListeners(); } void selectChannel(String channelId) { _selectedChannelId = channelId; notifyListeners(); } Future _loadChannelsForOrganization( String organizationId, { String? preferredChannelId, }) async { try { final response = await _invokeAuthedFunction( "channel-list", body: {"organization_id": organizationId}, ); final payload = _asMap(response.data); final channelRows = _asList(payload["channels"]); final channels = channelRows .map((row) => ChannelSummary.fromApi(_asMap(row))) .where((channel) => channel.id.isNotEmpty) .toList(); _channelsByOrganization[organizationId] = channels; if (channels.isEmpty) { _selectedChannelId = null; } else if (preferredChannelId != null && channels.any((c) => c.id == preferredChannelId)) { _selectedChannelId = preferredChannelId; } else { _selectedChannelId = channels.first.id; } } catch (error, stackTrace) { _logError("loadChannels/channel-list", error, stackTrace); _errorMessage = error.toString(); } } void _logError(String operation, Object error, StackTrace stackTrace) { debugPrint( "[CollaborationProvider] $operation failed (${error.runtimeType}): $error", ); debugPrintStack(stackTrace: stackTrace); } Future _invokeAuthedFunction( String functionName, { Object? body, }) async { final client = _supabaseProvider.client; var token = await _getFreshAccessToken(); if (token == null || token.isEmpty) { throw StateError( "No valid access token available for edge function call.", ); } Future invokeOnce(String accessToken) { client.functions.setAuth(accessToken); return client.functions.invoke( functionName, body: body, headers: {"Authorization": "Bearer $accessToken"}, ); } try { return await invokeOnce(token); } catch (error, stackTrace) { debugPrint( "[CollaborationProvider] invokeAuthedFunction/$functionName initial attempt failed: $error", ); debugPrintStack(stackTrace: stackTrace); if (!_isUnauthorizedFunctionError(error)) rethrow; // One forced refresh + retry for token rotation races. final refreshed = await client.auth.refreshSession(); token = refreshed.session?.accessToken ?? client.auth.currentSession?.accessToken; if (token == null || token.isEmpty) rethrow; return invokeOnce(token); } } Future _getFreshAccessToken() async { var session = _supabaseProvider.session; final nowUnix = DateTime.now().millisecondsSinceEpoch ~/ 1000; final expiresAt = session?.expiresAt; final shouldRefresh = session != null && expiresAt != null && expiresAt <= nowUnix + 30; if (shouldRefresh) { try { final refreshed = await _supabaseProvider.client.auth.refreshSession(); session = refreshed.session ?? _supabaseProvider.client.auth.currentSession; } catch (error, stackTrace) { debugPrint( "[CollaborationProvider] getFreshAccessToken/refreshSession failed: $error", ); debugPrintStack(stackTrace: stackTrace); session = _supabaseProvider.client.auth.currentSession; } } return session?.accessToken; } bool _isUnauthorizedFunctionError(Object error) { final text = error.toString(); return text.contains("status: 401") || text.contains("code: 401"); } static Map _asMap(Object? value) { if (value is Map) return value; if (value is Map) return value.cast(); return {}; } static List _asList(Object? value) { if (value is List) return value; return const []; } @override void dispose() { _supabaseProvider.removeListener(_handleSupabaseStateChange); super.dispose(); } }