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
10 changes: 6 additions & 4 deletions lib/ui/screens/add_podcast_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,23 @@ Future<void> showAddPodcastDialog(BuildContext context) async {
title: 'Add Podcast',
submitLabel: 'Add',
canSubmit: () => urlController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final url = urlController.text.trim();
if (url.isEmpty) return;

try {
await podcastProvider.add(url: url);
Navigator.pop(context);
showOverlay(context, caption: 'Podcast added');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Podcast added');
} catch (e) {
if (!sheetContext.mounted) return;
final message =
e is HttpResponseException && e.response.statusCode == 409
? 'You are already subscribed to this podcast.'
: 'Something wrong happened. Please try again.';
showOverlay(
context,
sheetContext,
caption: 'Error',
message: message,
icon: CupertinoIcons.exclamationmark_triangle,
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/create_playlist_folder_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ Future<void> showCreatePlaylistFolderDialog(BuildContext context) async {
title: 'New Folder',
submitLabel: 'Create',
canSubmit: () => controller.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = controller.text.trim();
if (name.isEmpty) return;

try {
await folderProvider.create(name: name);
Navigator.pop(context);
showOverlay(context, caption: 'Folder created');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Folder created');
} catch (_) {
showOverlay(context,
if (!sheetContext.mounted) return;
showOverlay(sheetContext,
caption: 'Error',
message: 'Could not create folder.',
icon: Icons.error_outline,
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/create_playlist_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Future<void> showCreatePlaylistDialog(BuildContext context) async {
title: 'New Playlist',
submitLabel: 'Create',
canSubmit: () => nameController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = nameController.text.trim();
if (name.isEmpty) return;

Expand All @@ -26,10 +26,12 @@ Future<void> showCreatePlaylistDialog(BuildContext context) async {
description: descController.text.trim(),
folderId: selectedFolderId,
);
Navigator.pop(context);
showOverlay(context, caption: 'Playlist added');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Playlist added');
} catch (_) {
showOverlay(context,
if (!sheetContext.mounted) return;
showOverlay(sheetContext,
caption: 'Error',
message: 'Could not create playlist.',
icon: Icons.error_outline,
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/edit_album_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Future<void> showEditAlbumDialog(
title: 'Edit Album',
submitLabel: 'Save',
canSubmit: () => nameController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = nameController.text.trim();
if (name.isEmpty) return;

Expand All @@ -28,11 +28,13 @@ Future<void> showEditAlbumDialog(

try {
await albumProvider.update(album, name: name, year: year);
Navigator.pop(context);
showOverlay(context, caption: 'Album updated');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Album updated');
} catch (_) {
if (!sheetContext.mounted) return;
showOverlay(
context,
sheetContext,
caption: 'Error',
message: 'Could not update album.',
icon: Icons.error_outline,
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/edit_artist_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,19 @@ Future<void> showEditArtistDialog(
title: 'Edit Artist',
submitLabel: 'Save',
canSubmit: () => nameController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = nameController.text.trim();
if (name.isEmpty) return;

try {
await artistProvider.update(artist, name: name);
Navigator.pop(context);
showOverlay(context, caption: 'Artist updated');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Artist updated');
} catch (_) {
if (!sheetContext.mounted) return;
showOverlay(
context,
sheetContext,
caption: 'Error',
message: 'Could not update artist.',
icon: Icons.error_outline,
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/edit_playlist_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Future<void> showEditPlaylistDialog(
title: 'Edit Playlist',
submitLabel: 'Save',
canSubmit: () => nameController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = nameController.text.trim();
if (name.isEmpty) return;

Expand All @@ -32,11 +32,13 @@ Future<void> showEditPlaylistDialog(
description: descController.text.trim(),
folderId: selectedFolderId,
);
Navigator.pop(context);
showOverlay(context, caption: 'Playlist updated');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Playlist updated');
} catch (_) {
if (!sheetContext.mounted) return;
showOverlay(
context,
sheetContext,
caption: 'Error',
message: 'Could not update playlist.',
icon: Icons.error_outline,
Expand Down
15 changes: 6 additions & 9 deletions lib/ui/screens/edit_radio_station_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Future<void> showEditRadioStationDialog(
canSubmit: () =>
nameController.text.trim().isNotEmpty &&
urlController.text.trim().isNotEmpty,
onSubmit: () async {
onSubmit: (sheetContext) async {
final name = nameController.text.trim();
final url = urlController.text.trim();
if (name.isEmpty || url.isEmpty) return;
Expand All @@ -42,11 +42,6 @@ Future<void> showEditRadioStationDialog(
isPublic: isPublic,
);

// If we just edited the station that's currently on air, the
// player is still streaming the old URL (its setUrl was called
// at play time). Restart the stream when the URL changed;
// otherwise just refresh the OS media-session metadata so the
// lock screen / notification picks up the new name.
if (radioPlayer.currentStation?.id == station.id) {
if (url != oldUrl) {
radioPlayer.play(station).catchError((_) {});
Expand All @@ -55,11 +50,13 @@ Future<void> showEditRadioStationDialog(
}
}

Navigator.pop(context);
showOverlay(context, caption: 'Station updated');
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
showOverlay(sheetContext, caption: 'Station updated');
} catch (_) {
if (!sheetContext.mounted) return;
showOverlay(
context,
sheetContext,
caption: 'Error',
message: 'Could not update station.',
icon: Icons.error_outline,
Expand Down
11 changes: 8 additions & 3 deletions lib/ui/widgets/form_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import 'package:flutter/services.dart';

/// A reusable bottom sheet with a title, form fields, and action buttons.
/// Used for creating/editing playlists, folders, radio stations, etc.
///
/// `onSubmit` receives the form sheet's own [BuildContext] — this is the
/// context callers should use for `Navigator.pop` / `showOverlay` after an
/// awaited network call. The outer context that opened the sheet may belong
/// to a route that's already been dismissed by the time `onSubmit` resumes.
Future<void> showFormSheet(
BuildContext context, {
required String title,
required Widget Function(BuildContext context, StateSetter setState) builder,
required String submitLabel,
required Future<void> Function() onSubmit,
required Future<void> Function(BuildContext context) onSubmit,
bool Function()? canSubmit,
}) async {
await showModalBottomSheet(
Expand Down Expand Up @@ -39,7 +44,7 @@ class _FormSheet extends StatefulWidget {
final String title;
final Widget Function(BuildContext context, StateSetter setState) builder;
final String submitLabel;
final Future<void> Function() onSubmit;
final Future<void> Function(BuildContext context) onSubmit;
final bool Function()? canSubmit;

const _FormSheet({
Expand Down Expand Up @@ -126,7 +131,7 @@ class _FormSheetState extends State<_FormSheet> {
: () async {
setState(() => _submitting = true);
try {
await widget.onSubmit();
await widget.onSubmit(context);
} finally {
if (mounted) {
setState(() => _submitting = false);
Expand Down
115 changes: 111 additions & 4 deletions test/ui/widgets/form_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ void main() {
context,
title: 'Test Form',
submitLabel: 'Submit',
onSubmit: () async {},
onSubmit: (_) async {},
builder: (context, setState) => Column(
children: [
TextField(decoration: InputDecoration(hintText: 'Field 1')),
Expand Down Expand Up @@ -55,7 +55,7 @@ void main() {
context,
title: 'Test Form',
submitLabel: 'Save',
onSubmit: () async {},
onSubmit: (_) async {},
builder: (context, setState) => Column(
children: [
TextField(decoration: InputDecoration(hintText: 'Field 1')),
Expand Down Expand Up @@ -111,9 +111,9 @@ void main() {
title: 'Test Form',
submitLabel: 'Go',
canSubmit: () => true,
onSubmit: () async {
onSubmit: (sheetContext) async {
submitted = true;
Navigator.pop(context);
Navigator.pop(sheetContext);
},
builder: (context, setState) =>
TextField(decoration: InputDecoration(hintText: 'Name')),
Expand All @@ -132,4 +132,111 @@ void main() {
expect(submitted, isTrue);
});
});

group('onSubmit context lifetime', () {
testWidgets(
'sheet closes via onSubmit context even after the route that opened '
'it is already gone',
(tester) async {
// Reproduces the artist/album action-sheet flow:
// 1. Action sheet's Edit row runs
// Navigator.pop(actionSheetContext)
// showEditDialog(actionSheetContext) // form sheet opens
// 2. The form sheet captures the now-doomed actionSheetContext.
// 3. Save tapped, awaits a network round-trip.
// 4. By the time onSubmit's body runs, actionSheetContext is
// defunct, so Navigator.pop on it silently fails.
//
// The fix: showFormSheet hands its own (always-mounted) context
// to onSubmit, so the body uses that for pop/showOverlay.

await tester.pumpWidget(buildTestApp(
child: Builder(
builder: (rootContext) => ElevatedButton(
onPressed: () {
Navigator.of(rootContext).push(MaterialPageRoute<void>(
builder: (innerContext) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(innerContext);
showFormSheet(
innerContext,
title: 'Edit',
submitLabel: 'Save',
canSubmit: () => true,
onSubmit: (sheetContext) async {
// Simulate a network round-trip; the inner
// route finishes its dismissal animation
// during this gap.
await Future<void>.delayed(
const Duration(milliseconds: 50),
);
if (!sheetContext.mounted) return;
Navigator.pop(sheetContext);
},
builder: (_, __) => const SizedBox(),
);
},
child: const Text('Edit'),
),
),
),
));
},
child: const Text('Open'),
),
),
));

await tester.tap(find.text('Open'));
await tester.pumpAndSettle();

await tester.tap(find.text('Edit'));
await tester.pumpAndSettle();

expect(find.text('Save'), findsOneWidget,
reason: 'form sheet should be open');

await tester.tap(find.text('Save'));
await tester.pumpAndSettle();

expect(find.text('Save'), findsNothing,
reason: 'form sheet must close after Save');
},
);

testWidgets('onSubmit receives a mounted context', (tester) async {
BuildContext? captured;

await tester.pumpWidget(buildTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showFormSheet(
context,
title: 'Test',
submitLabel: 'Save',
canSubmit: () => true,
onSubmit: (sheetContext) async {
captured = sheetContext;
Navigator.pop(sheetContext);
},
builder: (_, __) => const SizedBox(),
),
child: const Text('Open'),
),
),
));

await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();

expect(captured, isNotNull);
// After pop, the captured context should be unmounted — proving it
// was the form sheet's own context, not some outer ancestor.
expect(captured!.mounted, isFalse);
});
});
}
Loading