Skip to content

Swipe-left on rows to delete / unsubscribe (iOS-style)#189

Merged
phanan merged 4 commits into
masterfrom
feat/swipe-right-destructive-on-rows
May 2, 2026
Merged

Swipe-left on rows to delete / unsubscribe (iOS-style)#189
phanan merged 4 commits into
masterfrom
feat/swipe-right-destructive-on-rows

Conversation

@phanan
Copy link
Copy Markdown
Member

@phanan phanan commented May 1, 2026

Summary

Adds a left swipe (endToStart) on the playlist, radio-station, and podcast rows that triggers the destructive action — confirmed via dialog, gated on permissions where applicable. Matches the iOS Mail / Apple Music swipe-to-destroy pattern.

Row Swipe-left action Gating
Playlist Delete Playlist.canDelete
Radio Station Delete RadioStation.canDelete
Podcast Unsubscribe always (any subscriber can unsubscribe)

The playlist row is bidirectional: swipe-right keeps the existing "queue all songs" behavior (returning false from confirmDismiss so it bounces back), and swipe-left is the new destructive path. When canDelete is false, only startToEnd is allowed so the queue gesture still works.

Refactor

  • Pulled the confirm-action / execute-action helpers out of the long-press menus into public top-level functions (confirmDeletePlaylist / deletePlaylistWithFeedback, same shape for radio + podcast). The long-press menus and the new swipe handlers now share one path — confirm in confirmDismiss, execute in onDismissed.
  • New shared SwipeDestructiveBackground widget (red surface, trash icon, optional label) used as the secondaryBackground on all three rows.

Test plan

  • flutter test — full suite green (269/269); existing long-press tests still pass against the refactored helpers
  • flutter analyze clean on touched files
  • Smoke test: swipe-left on a playlist (with/without canDelete), a radio station (with/without canDelete), and a podcast — confirm the dialog → toast → row removal flow on each
  • Smoke test: swipe-right on a playlist still queues its songs (existing behavior preserved)

Summary by CodeRabbit

New Features

  • Playlist rows now support left-to-right swipes to queue songs and right-to-left swipes to delete.
  • Podcast list items now support right-to-left swipes to unsubscribe.
  • Radio station list items now support right-to-left swipes to delete (where applicable).
  • All destructive swipes display a red background with a trash icon for visual clarity.

Chores

  • Version updated to 2.2.8+45.

Playlist, radio-station, and podcast rows now respond to a left swipe
(endToStart) by triggering their respective destructive action,
gated on permissions where applicable. Matches iOS Mail / Apple
Music conventions.

- Playlist row: bidirectional Dismissible — swipe-right keeps the
  existing 'queue all songs' behavior; swipe-left confirms and
  deletes (only when Playlist.canDelete). Falls back to
  startToEnd-only when canDelete is false.
- Radio-station row: now wrapped in a Dismissible only when
  RadioStation.canDelete; swipe-left confirms and deletes.
- Podcast row: wrapped in a Dismissible (always — anyone can
  unsubscribe); swipe-left confirms and unsubscribes.

Refactor:
- Extracted the confirm-action and execute-action helpers from the
  long-press menus into public top-level functions so the swipe
  handlers and menus share one path.
- Added a shared SwipeDestructiveBackground widget (red surface,
  trash icon, optional label) for the secondaryBackground.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

📝 Walkthrough

Walkthrough

This PR implements swipe-to-delete gestures with confirmation dialogs across playlists, podcasts, and radio stations. A new SwipeDestructiveBackground widget provides the visual feedback. Confirmation and deletion logic are extracted into reusable functions. The podcast provider is optimized for optimistic removal with rollback on failure. The app version is bumped to 2.2.8+45.

Changes

Swipe-to-Delete with Confirmation Flows

Layer / File(s) Summary
Visual Component
lib/ui/widgets/swipe_destructive_background.dart, lib/ui/widgets/widgets.dart
New SwipeDestructiveBackground widget displays red destructive background with trash icon for swipe gestures; exported via barrel file.
Confirmation & Deletion Functions
lib/ui/widgets/playlist_row.dart, lib/ui/widgets/podcast_actions_menu.dart, lib/ui/widgets/radio_station_actions_menu.dart
Extracts confirm* and *WithFeedback helper functions for playlists, podcasts, and radio stations, separating confirmation UI logic from deletion execution.
Provider Optimization
lib/providers/podcast_provider.dart
unsubscribePodcast optimistically removes podcast from state, notifies listeners immediately, and restores on backend failure.
Screen List Integration
lib/ui/screens/playlists.dart, lib/ui/screens/podcasts.dart, lib/ui/screens/radio_stations.dart
Wraps list items in Dismissible with direction-aware swipe handling; playlists support startToEnd (queue) and endToStart (delete); podcasts and radio stations use endToStart deletion only; radio stations conditionally enable swipe based on canDelete permission.
Manifest Update
pubspec.yaml
Version bumped from 2.2.5+34 to 2.2.8+45.

Sequence Diagram

sequenceDiagram
    actor User
    participant Dismissible
    participant Dialog as Confirmation Dialog
    participant Provider as Data Provider
    participant Toast as Feedback Toast

    User->>Dismissible: Swipe item
    Dismissible->>Dialog: Show via confirmDismiss()
    Dialog->>User: Display confirmation prompt
    User->>Dialog: Confirm
    Dialog-->>Dismissible: Return true
    Dismissible->>Dismissible: Animate dismissal
    Dismissible->>Provider: Call via onDismissed()
    Provider->>Provider: Remove from state
    Provider->>Toast: Show feedback
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly Related PRs

Poem

🐰 With a swipe from the left and right so true,
Confirmation flows arrive on cue,
Red backgrounds glisten, trash icons gleam,
Optimistic updates fulfill our dream!
Podcasts vanish, playlists take flight,
Direction-aware deletion feels just right! 🗑️✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding swipe-left gesture support on rows to delete or unsubscribe, with iOS-style semantics.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/swipe-right-destructive-on-rows

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Trash icon alone — no 'Delete' / 'Unsubscribe' text on the swipe
drawer. Simpler and more iOS-Mail-like.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ios/Runner/Info.plist`:
- Around line 29-32: The LSApplicationQueriesSchemes entry in Info.plist uses
"https:" with a trailing colon which is invalid; open the Info.plist and under
the LSApplicationQueriesSchemes array replace the string value "https:" with the
plain scheme name "https" (remove the colon) so the system will correctly
recognize and whitelist the HTTPS scheme.

In `@lib/ui/screens/podcasts.dart`:
- Around line 108-111: The unsubscribe flow must perform optimistic removal: in
PodcastProvider.unsubscribePodcast remove the Podcast from the internal list
(_podcasts) and vault (_vault) and call notifyListeners() before awaiting the
network delete() call so the Dismissible is removed immediately; then await
delete('podcasts/${podcast.id}/subscriptions') inside a try/catch and on catch
restore the podcast into _podcasts and _vault, call notifyListeners() again and
rethrow the error. Ensure you reference the unsubscribePodcast method, the
_podcasts and _vault collections, notifyListeners(), and the delete() network
call when making these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f1712492-b4c5-4931-b080-fcb5cfdda092

📥 Commits

Reviewing files that changed from the base of the PR and between ce0797f and 69f969d.

📒 Files selected for processing (14)
  • ios/Flutter/AppFrameworkInfo.plist
  • ios/Runner.xcodeproj/project.pbxproj
  • ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  • ios/Runner/AppDelegate.swift
  • ios/Runner/Info.plist
  • lib/ui/screens/playlists.dart
  • lib/ui/screens/podcasts.dart
  • lib/ui/screens/radio_stations.dart
  • lib/ui/widgets/playlist_row.dart
  • lib/ui/widgets/podcast_actions_menu.dart
  • lib/ui/widgets/radio_station_actions_menu.dart
  • lib/ui/widgets/swipe_destructive_background.dart
  • lib/ui/widgets/widgets.dart
  • pubspec.yaml
💤 Files with no reviewable changes (1)
  • ios/Flutter/AppFrameworkInfo.plist

Comment thread ios/Runner/Info.plist Outdated
Comment on lines +29 to +32
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https:</string>
</array>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the trailing colon from the query scheme.

LSApplicationQueriesSchemes expects plain scheme names, so https: will not match and the whitelist entry is ineffective.

Suggested fix
  <key>LSApplicationQueriesSchemes</key>
  <array>
-		<string>https:</string>
+		<string>https</string>
  </array>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https:</string>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Runner/Info.plist` around lines 29 - 32, The LSApplicationQueriesSchemes
entry in Info.plist uses "https:" with a trailing colon which is invalid; open
the Info.plist and under the LSApplicationQueriesSchemes array replace the
string value "https:" with the plain scheme name "https" (remove the colon) so
the system will correctly recognize and whitelist the HTTPS scheme.

Comment on lines +108 to +111
onDismissed: (_) => unsubscribePodcastWithFeedback(
context,
podcast: podcast,
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

provider_file="$(fd -i 'podcast_provider.dart' lib/providers | head -n1)"
if [[ -z "${provider_file:-}" ]]; then
  echo "podcast_provider.dart not found under lib/providers"
  exit 1
fi

echo "Inspecting: $provider_file"
rg -nP -C6 'Future<\s*void\s*>\s*unsubscribePodcast\s*\(' "$provider_file"
rg -nP -C4 '_podcasts\.remove|notifyListeners|await|delete\(' "$provider_file"

Repository: koel/player

Length of output: 2085


Fix podcast dismissal by using optimistic removal instead of awaiting the network call first.

The unsubscribePodcast implementation in PodcastProvider awaits the network delete before removing from the local list. This breaks the Dismissible contract: when onDismissed fires, the item must be removed from the source list immediately. A rebuild triggered by notifyListeners() after the network call could occur while the Dismissible is still animating, causing a "dismissed widget still part of the tree" error.

Remove the item from _podcasts and call notifyListeners() before the await delete(). If the network call fails, restore the item:

Future<void> unsubscribePodcast(Podcast podcast) async {
  _podcasts.remove(podcast);
  _vault.remove(podcast.id);
  notifyListeners(); // UI updates immediately
  
  try {
    await delete('podcasts/${podcast.id}/subscriptions');
  } catch (e) {
    // Restore on network failure
    _podcasts.add(podcast);
    _vault[podcast.id] = podcast;
    notifyListeners();
    rethrow;
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ui/screens/podcasts.dart` around lines 108 - 111, The unsubscribe flow
must perform optimistic removal: in PodcastProvider.unsubscribePodcast remove
the Podcast from the internal list (_podcasts) and vault (_vault) and call
notifyListeners() before awaiting the network delete() call so the Dismissible
is removed immediately; then await
delete('podcasts/${podcast.id}/subscriptions') inside a try/catch and on catch
restore the podcast into _podcasts and _vault, call notifyListeners() again and
rethrow the error. Ensure you reference the unsubscribePodcast method, the
_podcasts and _vault collections, notifyListeners(), and the delete() network
call when making these changes.

phanan added 2 commits May 2, 2026 10:34
These files are TestFlight build state + an in-progress Apple-migration
local change set the user has been carrying as unstaged. They leaked
into this branch when I resolved a stash/pull conflict with
'git checkout --theirs ios/Runner/Info.plist'. Reverting to master so
the PR is just the swipe-to-delete change set.

The trailing-colon issue in LSApplicationQueriesSchemes (https: vs
https) flagged in review still exists on master but is out of scope
for this PR.
Address review: a Dismissible's onDismissed callback expects the
source list to drop the item synchronously — awaiting the network
delete before mutating left the dismissed widget in the tree
during the animation, which can throw 'dismissed widget still part
of the tree'. Mirror PlaylistProvider.remove's pattern: remove
locally + notify, then await the delete, restore on failure.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/providers/podcast_provider.dart`:
- Around line 47-54: Before performing the optimistic removal, record the
podcast's current index from _podcasts (e.g., store in a local variable like
removedIndex); then keep the current try/catch logic but in the catch block
restore the podcast using _podcasts.insert(removedIndex, podcast) instead of
_podcasts.add(podcast), continue to restore _vault[podcast.id] = podcast and
call notifyListeners(), and rethrow; this ensures the item is restored to its
original position when delete('podcasts/${podcast.id}/subscriptions') fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdf50069-4489-4cc2-a45a-1c3360c919ff

📥 Commits

Reviewing files that changed from the base of the PR and between 69f969d and 2ee563d.

📒 Files selected for processing (1)
  • lib/providers/podcast_provider.dart

Comment on lines +47 to +54
try {
await delete('podcasts/${podcast.id}/subscriptions');
} catch (_) {
_podcasts.add(podcast);
_vault[podcast.id] = podcast;
notifyListeners();
rethrow;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rollback restores the podcast at the wrong list position

_podcasts.add(podcast) on line 50 always appends to the end of the list. When the network call fails and the item is restored, it reappears at the bottom instead of its original position, which is visually confusing to the user.

Capture the index before the optimistic removal and use insert during rollback:

🛡️ Proposed fix to preserve list position on rollback
 Future<void> unsubscribePodcast(Podcast podcast) async {
     // Optimistic removal so a Dismissible's onDismissed callback can
     // call this without leaving the dismissed widget in the tree
     // while the network call is in flight. Restore on failure.
+    final index = _podcasts.indexOf(podcast);
     _podcasts.remove(podcast);
     _vault.remove(podcast.id);
     notifyListeners();

     try {
       await delete('podcasts/${podcast.id}/subscriptions');
     } catch (_) {
-      _podcasts.add(podcast);
+      if (index >= 0) {
+        _podcasts.insert(index, podcast);
+      } else {
+        _podcasts.add(podcast);
+      }
       _vault[podcast.id] = podcast;
       notifyListeners();
       rethrow;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/providers/podcast_provider.dart` around lines 47 - 54, Before performing
the optimistic removal, record the podcast's current index from _podcasts (e.g.,
store in a local variable like removedIndex); then keep the current try/catch
logic but in the catch block restore the podcast using
_podcasts.insert(removedIndex, podcast) instead of _podcasts.add(podcast),
continue to restore _vault[podcast.id] = podcast and call notifyListeners(), and
rethrow; this ensures the item is restored to its original position when
delete('podcasts/${podcast.id}/subscriptions') fails.

@phanan phanan merged commit 2c5a369 into master May 2, 2026
2 checks passed
@phanan phanan deleted the feat/swipe-right-destructive-on-rows branch May 2, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant