Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions examples/database_crud/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,27 +158,9 @@ class _TasksPageState extends State<TasksPage> {
}

Future<void> _rename(Task task) async {
final controller = TextEditingController(text: task.title);
final title = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Rename task'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Title'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('Save'),
),
],
),
builder: (context) => _RenameDialog(initialTitle: task.title),
);
if (title == null || title.isEmpty) return;
await _mutate(() => _repository.renameTask(id: task.id, title: title));
Expand Down Expand Up @@ -374,6 +356,52 @@ class _TaskFormResult {
final Priority priority;
}

/// Asks for a new title for an existing task.
///
/// Owns its [TextEditingController] so that it is disposed together with the
/// dialog. Creating the controller in the caller instead would either leak it
/// or dispose it while the route is still animating out.
class _RenameDialog extends StatefulWidget {
const _RenameDialog({required this.initialTitle});

final String initialTitle;

@override
State<_RenameDialog> createState() => _RenameDialogState();
}

class _RenameDialogState extends State<_RenameDialog> {
late final _title = TextEditingController(text: widget.initialTitle);

@override
void dispose() {
_title.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rename task'),
content: TextField(
controller: _title,
autofocus: true,
decoration: const InputDecoration(labelText: 'Title'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _title.text.trim()),
child: const Text('Save'),
),
],
);
}
}

class _TaskDialog extends StatefulWidget {
const _TaskDialog({required this.projects});

Expand Down
67 changes: 48 additions & 19 deletions examples/passkeys/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,52 @@ class _SignInViewState extends State<SignInView> {
}
}

/// Asks for a new friendly name for an existing passkey.
///
/// Owns its [TextEditingController] so that it is disposed together with the
/// dialog. Creating the controller in the caller instead would either leak it
/// or dispose it while the route is still animating out.
class _RenameDialog extends StatefulWidget {
const _RenameDialog({required this.initialName});

final String initialName;

@override
State<_RenameDialog> createState() => _RenameDialogState();
}

class _RenameDialogState extends State<_RenameDialog> {
late final _name = TextEditingController(text: widget.initialName);

@override
void dispose() {
_name.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rename passkey'),
content: TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(labelText: 'Friendly name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _name.text.trim()),
child: const Text('Save'),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
],
);
}
}

/// Lists, registers, renames and deletes passkeys for the signed in user.
class SignedInView extends StatefulWidget {
const SignedInView({super.key});
Expand Down Expand Up @@ -201,27 +247,10 @@ class _SignedInViewState extends State<SignedInView> {
}

Future<void> _rename(Passkey passkey) async {
final controller = TextEditingController(text: passkey.friendlyName);
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Rename passkey'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Friendly name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Save'),
),
],
),
builder: (context) =>
_RenameDialog(initialName: passkey.friendlyName ?? ''),
);
if (name == null || name.isEmpty) return;
try {
Expand Down
74 changes: 64 additions & 10 deletions packages/supabase_flutter/lib/src/supabase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,22 @@ class Supabase {
/// Whether the Supabase instance has been initialized. Useful for debugging.
bool get isInitialized => _isInitialized;

SupabaseClient? _client;

/// The supabase client for this instance
///
/// Throws an error if [Supabase.initialize] was not called.
late SupabaseClient client;
/// Throws a [StateError] if [Supabase.initialize] was not called, or if the
/// instance has since been disposed.
SupabaseClient get client {
final currentClient = _client;
if (currentClient == null) {
throw StateError(
'You must initialize the supabase instance before calling '
'Supabase.instance.client',
);
}
return currentClient;
}

SupabaseAuth? _supabaseAuth;

Expand Down Expand Up @@ -202,14 +214,56 @@ class Supabase {
StreamSubscription<dynamic>? _logSubscription;

/// Dispose the instance to free up resources.
///
/// Calling this on an instance that is not initialized does nothing, so it
/// is safe to call more than once.
Future<void> dispose() async {
_targetLifecycleState = null;
await _restoreSessionCancellableOperation?.cancel();
await _logSubscription?.cancel();
await client.dispose();
_instance._supabaseAuth?.dispose();
_lifecycleListener?.dispose();
final currentClient = _client;
if (currentClient == null) return;

final supabaseAuth = _supabaseAuth;
final lifecycleListener = _lifecycleListener;
final restoreSession = _restoreSessionCancellableOperation;
final logSubscription = _logSubscription;
final pendingLifecycleOperation = _pendingLifecycleOperation;

_client = null;
_supabaseAuth = null;
_restoreSessionCancellableOperation = null;
_lifecycleListener = null;
_logSubscription = null;
_isInitialized = false;

_targetLifecycleState = null;
lifecycleListener?.dispose();
Comment thread
spydon marked this conversation as resolved.

await _disposeAll([
() => restoreSession?.cancel(),
() => logSubscription?.cancel(),
() => pendingLifecycleOperation,
currentClient.dispose,
() => supabaseAuth?.dispose(),
]);
}

/// Runs every step, then rethrows the first error any of them threw.
static Future<void> _disposeAll(List<FutureOr<void> Function()> steps) async {
Object? firstError;
StackTrace? firstStackTrace;

for (final step in steps) {
try {
await step();
} catch (error, stackTrace) {
_log.warning('Error while disposing Supabase', error, stackTrace);
firstError ??= error;
firstStackTrace ??= stackTrace;
}
}

if (firstError != null) {
Error.throwWithStackTrace(firstError, firstStackTrace!);
}
}

void _init(
Expand All @@ -228,7 +282,7 @@ class Supabase {
...Constants.defaultHeaders,
...?customHeaders,
};
client = SupabaseClient(
final newClient = _client = SupabaseClient(
supabaseUrl,
supabaseKey,
httpClient: httpClient,
Expand All @@ -245,7 +299,7 @@ class Supabase {
// flutter web hot-restart.
if (kDebugMode) {
disposePreviousClient();
markClientToDispose(client);
markClientToDispose(newClient);
}

_setupLifecycleListener();
Expand Down
26 changes: 26 additions & 0 deletions packages/supabase_flutter/test/initialization_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,32 @@ void main() {
);
});

test('dispose drops the reference to the client', () async {
await Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
debug: false,
);

final supabase = Supabase.instance;
await supabase.dispose();

expect(() => supabase.client, throwsStateError);
});

test('dispose can be called more than once', () async {
await Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
debug: false,
);

final supabase = Supabase.instance;
await supabase.dispose();

await expectLater(supabase.dispose(), completes);
});

test('handles multiple initializations correctly', () async {
await Supabase.initialize(
url: supabaseUrl,
Expand Down
19 changes: 11 additions & 8 deletions packages/supabase_flutter/test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@ void main() {
testWidgets('Signing out triggers AuthChangeEvent.signedOut event', (
tester,
) async {
// Initialize the Supabase singleton
await Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
debug: false,
authOptions: FlutterAuthClientOptions(
localStorage: const MockLocalStorage(),
pkceAsyncStorage: MockAsyncStorage(),
await tester.runAsync(
() => Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
debug: false,
authOptions: FlutterAuthClientOptions(
localStorage: const MockLocalStorage(),
pkceAsyncStorage: MockAsyncStorage(),
),
),
);
addTearDown(() => tester.runAsync(() => Supabase.instance.dispose()));

Supabase.instance.client.auth.stopAutoRefresh();
await tester.pumpWidget(const MaterialApp(home: MockWidget()));
await tester.tap(find.text('Sign out'));
Expand Down
11 changes: 10 additions & 1 deletion packages/supabase_flutter/test/widget_test_stubs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class MockWidget extends StatefulWidget {

class _MockWidgetState extends State<MockWidget> {
bool isSignedIn = true;
StreamSubscription<AuthState>? _authSubscription;

@override
Widget build(BuildContext context) {
Expand All @@ -37,14 +38,22 @@ class _MockWidgetState extends State<MockWidget> {
@override
void initState() {
super.initState();
Supabase.instance.client.auth.onAuthStateChange.listen((data) {
_authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen((
data,
) {
if (data.event == AuthChangeEvent.signedOut) {
setState(() {
isSignedIn = false;
});
}
});
}

@override
void dispose() {
unawaited(_authSubscription?.cancel());
super.dispose();
}
}

/// Local storage that returns an expired session
Expand Down
Loading
Loading