Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:supabase_flutter/supabase_flutter.dart";
|
||||
|
||||
SupabaseClient get supabase => Supabase.instance.client;
|
||||
|
||||
class SupabaseProvider extends ChangeNotifier {
|
||||
Session? _session;
|
||||
StreamSubscription<AuthState>? _authSub;
|
||||
bool _isSessionValidated = false;
|
||||
bool _isValidatingSession = false;
|
||||
|
||||
SupabaseProvider() {
|
||||
_session = supabase.auth.currentSession;
|
||||
unawaited(_validateSession(_session));
|
||||
|
||||
_authSub = supabase.auth.onAuthStateChange.listen((data) {
|
||||
unawaited(_validateSession(data.session));
|
||||
});
|
||||
}
|
||||
|
||||
Session? get session => _session;
|
||||
bool get isAuthenticated => _session != null && _isSessionValidated;
|
||||
bool get isValidatingSession => _isValidatingSession;
|
||||
SupabaseClient get client => supabase;
|
||||
|
||||
|
||||
Future<void> signInWithPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
await supabase.auth.signInWithPassword(email: email, password: password);
|
||||
}
|
||||
|
||||
Future<bool> signUpWithPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await supabase.auth.signUp(email: email, password: password);
|
||||
|
||||
// returns true if email confirmation is needed
|
||||
return res.session == null;
|
||||
}
|
||||
|
||||
Future<void> verifySignUpOtp({
|
||||
required String email,
|
||||
required String token,
|
||||
}) async {
|
||||
await supabase.auth.verifyOTP(
|
||||
email: email,
|
||||
token: token,
|
||||
type: OtpType.signup,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> resendSignUpOtp({required String email}) async {
|
||||
await supabase.auth.resend(type: OtpType.signup, email: email);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await supabase.auth.signOut(scope: SignOutScope.local);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[SupabaseProvider] signOut failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
_session = null;
|
||||
_isSessionValidated = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _validateSession(Session? incomingSession) async {
|
||||
_session = incomingSession ?? supabase.auth.currentSession;
|
||||
|
||||
if (_session == null) {
|
||||
_isSessionValidated = false;
|
||||
_isValidatingSession = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_isValidatingSession = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final userResponse = await supabase.auth.getUser();
|
||||
if (userResponse.user != null) {
|
||||
_session = supabase.auth.currentSession ?? _session;
|
||||
_isSessionValidated = true;
|
||||
_isValidatingSession = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[SupabaseProvider] validateSession/getUser failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
try {
|
||||
final refreshed = await supabase.auth.refreshSession();
|
||||
_session = refreshed.session ?? supabase.auth.currentSession;
|
||||
final refreshedUser = await supabase.auth.getUser();
|
||||
_isSessionValidated = refreshedUser.user != null;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[SupabaseProvider] validateSession/refresh failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
_isSessionValidated = false;
|
||||
}
|
||||
|
||||
if (!_isSessionValidated) {
|
||||
try {
|
||||
await supabase.auth.signOut(scope: SignOutScope.local);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("[SupabaseProvider] validateSession/signOut failed: $error");
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
_session = null;
|
||||
}
|
||||
|
||||
_isValidatingSession = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user