Commit:
7567369Parent:
b7cef76Add swipe-to-delete for notes
Swiping a row in the note list either direction reveals a red delete background via a shared ItemTouchHelper.SimpleCallback, then prompts for confirmation before anything happens - cancelling (button or dismissal) snaps the row back with NotifyItemChanged, confirming deletes the file from OneDrive (GraphService.DeleteNoteAsync) and removes it from the cache and adapter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/Activities/NoteListActivity.cs
+52
-0
diff --git a/src/Activities/NoteListActivity.cs b/src/Activities/NoteListActivity.cs
index 84d5011..5c1c5f3 100644
@@ -9,6 +9,7 @@ using NotesApp.Graph;
using NotesApp.Models;
using NotesApp.Storage;
using NotesApp.Views;
using AlertDialog = AndroidX.AppCompat.App.AlertDialog;
using Toolbar = AndroidX.AppCompat.Widget.Toolbar;
namespace NotesApp.Activities;
@@ -45,6 +46,10 @@ public class NoteListActivity : AppCompatActivity
FindViewById<FloatingActionButton>(Resource.Id.new_note_fab)!.Click += (_, _) =>
OnNewNoteClicked();
new ItemTouchHelper(new SwipeToDeleteCallback(OnNoteSwiped, this)).AttachToRecyclerView(
_recyclerView
);
_graphService = new GraphService(this);
// Cache renders immediately, scrolled to the bottom, before any network call.
@@ -159,6 +164,53 @@ public class NoteListActivity : AppCompatActivity
StartActivityForResult(intent, EditNoteRequestCode);
}
private void OnNoteSwiped(int position)
{
var entry = _adapter.GetItem(position);
var dialog = new AlertDialog.Builder(this)!
.SetTitle(Resource.String.delete_note_title)!
.SetMessage(string.Format(GetString(Resource.String.delete_note_message), entry.Name))!
.SetPositiveButton(
Resource.String.delete,
(_, _) => _ = DeleteNoteAsync(entry, position)
)!
.SetNegativeButton(
Resource.String.cancel,
(_, _) => _adapter.NotifyItemChanged(position)
)!
.Create()!;
// Covers dismissal via back press or tapping outside the dialog - button clicks
// dismiss without cancelling, so this doesn't double-fire alongside SetNegativeButton.
dialog.CancelEvent += (_, _) => _adapter.NotifyItemChanged(position);
dialog.Show();
}
private async Task DeleteNoteAsync(NoteCacheEntry entry, int position)
{
_progressBar.Visibility = ViewStates.Visible;
try
{
await _graphService.DeleteNoteAsync(entry.Id);
var list = NoteCacheStore.Load();
list.RemoveAll(e => e.Id == entry.Id);
NoteCacheStore.Save(list);
_adapter.RemoveAt(position);
}
catch (Exception ex)
{
var message = string.Format(GetString(Resource.String.delete_failed), ex.Message);
Toast.MakeText(this, message, ToastLength.Long)?.Show();
_adapter.NotifyItemChanged(position);
}
finally
{
_progressBar.Visibility = ViewStates.Invisible;
}
}
private void BindItems(List<NoteCacheEntry> entries)
{
_adapter.SetItems(entries);
src/Adapters/NoteListAdapter.cs
+8
-0
diff --git a/src/Adapters/NoteListAdapter.cs b/src/Adapters/NoteListAdapter.cs
index 7809e7b..cda08f3 100644
@@ -16,6 +16,14 @@ public class NoteListAdapter(Action<NoteCacheEntry> onNoteTapped) : RecyclerView
NotifyDataSetChanged();
}
public NoteCacheEntry GetItem(int position) => _items[position];
public void RemoveAt(int position)
{
_items.RemoveAt(position);
NotifyItemRemoved(position);
}
public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType)
{
var view = LayoutInflater
src/Graph/GraphService.cs
+6
-0
diff --git a/src/Graph/GraphService.cs b/src/Graph/GraphService.cs
index 5f4625d..b2fd760 100644
@@ -245,4 +245,10 @@ public class GraphService
var (eTag, lastModified, size) = await PutContentAsync(newId, content, ct);
return new NoteCacheEntry(newId, fileName, eTag, lastModified, preview, size);
}
public async Task DeleteNoteAsync(string itemId, CancellationToken ct = default)
{
var driveId = await GetDriveIdAsync(ct);
await _client.Drives[driveId].Items[itemId].DeleteAsync(cancellationToken: ct);
}
}
src/Resources/drawable/ic_delete_24.xml
+13
-0
diff --git a/src/Resources/drawable/ic_delete_24.xml b/src/Resources/drawable/ic_delete_24.xml
new file mode 100644
index 0000000..ddd506b
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7L6,19zM19,4h-3.5l-1,-1h-5l-1,1L5,4v2h14L19,4z"
/>
</vector>
src/Resources/values/strings.xml
+5
-0
diff --git a/src/Resources/values/strings.xml b/src/Resources/values/strings.xml
index 53c38ae..3a50b9f 100644
@@ -24,4 +24,9 @@
<string name="discard">Discard</string>
<string name="cancel">Cancel</string>
<string name="save_failed">Save failed: {0}</string>
<string name="delete_note_title">Delete note?</string>
<string name="delete_note_message">{0} will be permanently deleted.</string>
<string name="delete">Delete</string>
<string name="delete_failed">Delete failed: {0}</string>
</resources>
src/Views/SwipeToDeleteCallback.cs
+91
-0
diff --git a/src/Views/SwipeToDeleteCallback.cs b/src/Views/SwipeToDeleteCallback.cs
new file mode 100644
index 0000000..48afac7
@@ -0,0 +1,91 @@
using Android.Content;
using Android.Graphics;
using Android.Graphics.Drawables;
using AndroidX.Core.Content;
using AndroidX.RecyclerView.Widget;
namespace NotesApp.Views;
// Renders the red delete background + trash icon behind a row as it's swiped. OnSwiped only
// reports the position - the caller decides whether to actually delete (after confirmation)
// or snap the row back via NotifyItemChanged.
public class SwipeToDeleteCallback(Action<int> onSwiped, Context context)
: ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.Left | ItemTouchHelper.Right)
{
private const int IconMarginDp = 16;
private readonly ColorDrawable _background = new(Color.ParseColor("#D32F2F"));
private readonly Drawable? _icon = ContextCompat.GetDrawable(
context,
Resource.Drawable.ic_delete_24
);
private readonly float _density = context.Resources!.DisplayMetrics!.Density;
public override bool OnMove(
RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder,
RecyclerView.ViewHolder target
) => false;
public override void OnSwiped(RecyclerView.ViewHolder viewHolder, int direction) =>
onSwiped(viewHolder.BindingAdapterPosition);
public override void OnChildDraw(
Canvas c,
RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder,
float dX,
float dY,
int actionState,
bool isCurrentlyActive
)
{
var itemView = viewHolder.ItemView;
var iconMargin = (int)(IconMarginDp * _density);
var iconWidth = _icon?.IntrinsicWidth ?? 0;
var iconHeight = _icon?.IntrinsicHeight ?? 0;
var iconTop = itemView.Top + (itemView.Height - iconHeight) / 2;
var iconBottom = iconTop + iconHeight;
if (dX > 0)
{
_background.SetBounds(
itemView.Left,
itemView.Top,
itemView.Left + (int)dX,
itemView.Bottom
);
_icon?.SetBounds(
itemView.Left + iconMargin,
iconTop,
itemView.Left + iconMargin + iconWidth,
iconBottom
);
}
else if (dX < 0)
{
_background.SetBounds(
itemView.Right + (int)dX,
itemView.Top,
itemView.Right,
itemView.Bottom
);
_icon?.SetBounds(
itemView.Right - iconMargin - iconWidth,
iconTop,
itemView.Right - iconMargin,
iconBottom
);
}
else
{
_background.SetBounds(0, 0, 0, 0);
}
_background.Draw(c);
if (dX != 0)
_icon?.Draw(c);
base.OnChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}