Roadbound-BRR/lib/provider/collaboration_state.dart

536 lines
16 KiB
Dart

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<String, dynamic> map) {
final org =
(map["organization"] as Map?)?.cast<String, dynamic>() ?? 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<String, dynamic> 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<String, dynamic> 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<OrganizationSummary> _organizations = const [];
final Map<String, List<ChannelSummary>> _channelsByOrganization = {};
String? _selectedOrganizationId;
String? _selectedChannelId;
bool get isLoadingOrganizations => _isLoadingOrganizations;
String? get errorMessage => _errorMessage;
List<OrganizationSummary> get organizations => _organizations;
String? get selectedOrganizationId => _selectedOrganizationId;
String? get selectedChannelId => _selectedChannelId;
List<ChannelSummary> channelsForOrganization(String orgId) =>
_channelsByOrganization[orgId] ?? const [];
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
if (!_supabaseProvider.isAuthenticated) return;
await refreshOrganizations();
}
Future<void> 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<void> 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<void> updateOrganization({
required String organizationId,
String? name,
String? iconUrl,
}) async {
final trimmedName = name?.trim();
try {
final body = <String, Object?>{"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<void> 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<void> 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<void> 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<List<MessageSummary>> 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<void> sendMessage({
required String channelId,
required String content,
}) async {
await _invokeAuthedFunction(
"message-send",
body: {"channel_id": channelId, "content": content},
);
}
Future<String> 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<String> 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<void> 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<void> _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<dynamic> _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<dynamic> invokeOnce(String accessToken) {
client.functions.setAuth(accessToken);
return client.functions.invoke(
functionName,
body: body,
headers: <String, String>{"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<String?> _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<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return {};
}
static List<dynamic> _asList(Object? value) {
if (value is List) return value;
return const [];
}
@override
void dispose() {
_supabaseProvider.removeListener(_handleSupabaseStateChange);
super.dispose();
}
}