536 lines
16 KiB
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();
|
|
}
|
|
}
|