📄 src/Activities/NoteListActivity.cs
using Android.Content;
using Android.Views;
using AndroidX.AppCompat.App;
using AndroidX.RecyclerView.Widget;
using Google.Android.Material.FloatingActionButton;
using Google.Android.Material.ProgressIndicator;
using NotesApp.Adapters;
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;

[Activity(Label = "@string/app_name", Exported = false)]
public class NoteListActivity : AppCompatActivity
{
    private const int EditNoteRequestCode = 300;

    private RecyclerView _recyclerView = null!;
    private LinearProgressIndicator _progressBar = null!;
    private NoteListAdapter _adapter = null!;
    private GraphService _graphService = null!;
    private bool _hasScrolledToBottom;

    protected override void OnCreate(Bundle? savedInstanceState)
    {
        AndroidX.Activity.EdgeToEdge.Enable(this);
        base.OnCreate(savedInstanceState);
        SetContentView(Resource.Layout.activity_note_list);

        EdgeToEdgeHelper.Apply(FindViewById(Resource.Id.note_list_root)!);

        SetSupportActionBar(FindViewById<Toolbar>(Resource.Id.note_list_toolbar));

        _recyclerView = FindViewById<RecyclerView>(Resource.Id.note_recycler_view)!;
        _recyclerView.SetLayoutManager(new LinearLayoutManager(this));

        _adapter = new NoteListAdapter(OnNoteTapped);
        _recyclerView.SetAdapter(_adapter);

        _progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_refresh_progress)!;

        FindViewById<FloatingActionButton>(Resource.Id.new_note_fab)!.Click += (_, _) =>
            _ = OnNewNoteClickedAsync();

        new ItemTouchHelper(new SwipeToDeleteCallback(OnNoteSwiped, this)).AttachToRecyclerView(
            _recyclerView
        );

        _graphService = new GraphService(this);
        NoteContentCache.ContentFetched += OnNoteContentFetched;

        // Cache renders immediately, scrolled to the bottom, before any network call.
        BindItems(NoteCacheStore.Load());

        _ = RefreshAsync();
    }

    protected override void OnDestroy()
    {
        NoteContentCache.ContentFetched -= OnNoteContentFetched;
        base.OnDestroy();
    }

    // Fires whenever a prefetch or an on-demand open in NoteViewActivity finishes fetching a
    // note's full body - refreshes that row's preview from it, since GetPreviewsAsync's
    // byte-range fetch can cut off mid-frontmatter and doesn't strip it.
    private void OnNoteContentFetched(string noteId, string content)
    {
        var preview = NotePreviewBuilder.Build(content);
        _adapter.UpdateItem(noteId, preview);

        var list = NoteCacheStore.Load();
        var index = list.FindIndex(e => e.Id == noteId);
        if (index < 0 || list[index].Preview == preview)
            return;

        list[index] = list[index] with { Preview = preview };
        NoteCacheStore.Save(list);
    }

    private async Task RefreshAsync()
    {
        var folderId = AppSettings.RootFolderId;
        if (folderId is null)
            return;

        _progressBar.Visibility = ViewStates.Visible;
        try
        {
            var cacheById = NoteCacheStore.Load().ToDictionary(e => e.Id);

            var remoteNotes = await _graphService.ListMarkdownFilesAsync(folderId);

            var changedIds = remoteNotes
                .Where(n =>
                    !cacheById.TryGetValue(n.Id, out var existing) || existing.ETag != n.ETag
                )
                .Select(n => n.Id)
                .ToList();

            var freshPreviews = await _graphService.GetPreviewsAsync(changedIds);

            var merged = remoteNotes
                .Select(n => new NoteCacheEntry(
                    n.Id,
                    n.Name,
                    n.ETag,
                    n.LastModifiedUtc,
                    freshPreviews.TryGetValue(n.Id, out var preview) ? preview
                        : cacheById.TryGetValue(n.Id, out var existing) ? existing.Preview
                        : "",
                    n.Size
                ))
                .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
                .ToList();

            NoteCacheStore.Save(merged);
            BindItems(merged);
        }
        catch (Exception ex)
        {
            Toast.MakeText(this, ex.Message, ToastLength.Long)?.Show();
        }
        finally
        {
            _progressBar.Visibility = ViewStates.Invisible;
        }
    }

    public override bool OnCreateOptionsMenu(IMenu? menu)
    {
        MenuInflater.Inflate(Resource.Menu.menu_note_list, menu);
        return true;
    }

    public override bool OnOptionsItemSelected(IMenuItem item)
    {
        if (item.ItemId == Resource.Id.action_settings)
        {
            StartActivity(new Intent(this, typeof(SettingsActivity)));
            return true;
        }
        return base.OnOptionsItemSelected(item);
    }

    protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
    {
        base.OnActivityResult(requestCode, resultCode, data);

        if (requestCode != EditNoteRequestCode || resultCode != Result.Ok)
            return;

        var updatedEntry = NoteResultIntent.TryGet(data);
        if (updatedEntry is null)
            return;

        var list = NoteCacheStore.Load();
        var wasNew = list.RemoveAll(e => e.Id == updatedEntry.Id) == 0;
        list.Add(updatedEntry);
        var merged = list.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase).ToList();
        NoteCacheStore.Save(merged);

        _adapter.SetItems(merged);

        // A newly created note that landed at the bottom should be scrolled into view,
        // regardless of the usual "only auto-scroll on first load" guard in BindItems.
        var index = merged.FindIndex(e => e.Id == updatedEntry.Id);
        if (wasNew && index == merged.Count - 1)
            _recyclerView.ScrollToPosition(index);
    }

    private async Task OnNewNoteClickedAsync()
    {
        var fileName = DateTime.Now.ToString("yyyy-MM-ddTHHmmss") + ".md";
        var initialContent = await BuildInitialContentAsync();

        var intent = new Intent(this, typeof(NoteViewActivity));
        intent.PutExtra(NoteViewActivity.ExtraNoteName, fileName);
        intent.PutExtra(NoteViewActivity.ExtraInitialContent, initialContent);
        StartActivityForResult(intent, EditNoteRequestCode);
    }

    // Mirrors the companion CLI's New-Note: carries over the previous note's frontmatter
    // fields (if any) into a fresh block stamped with today's date, so recurring fields don't
    // need retyping. Falls back to a bare dated block if there's no previous note, or its
    // content can't be fetched - that fetch failing shouldn't block creating a new note.
    private async Task<string> BuildInitialContentAsync()
    {
        var mostRecent = _adapter.ItemCount > 0 ? _adapter.GetItem(_adapter.ItemCount - 1) : null;
        var fields = new Dictionary<string, object>();

        if (mostRecent != null)
        {
            try
            {
                var content = await NoteContentCache.GetOrStart(
                    mostRecent.Id,
                    () => _graphService.GetContentAsync(mostRecent.Id)
                );
                fields = NoteFrontmatter.Extract(content) ?? fields;
            }
            catch { }
        }

        return NoteFrontmatter.BuildBlock(fields);
    }

    private void OnNoteTapped(NoteCacheEntry entry)
    {
        var intent = new Intent(this, typeof(NoteViewActivity));
        NoteResultIntent.Put(intent, entry);
        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);
            NoteContentCache.Remove(entry.Id);

            _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);

        // Only force scroll-to-bottom on the initial (cache-empty) load; don't yank the
        // scroll position if the user is already reading older notes when a refresh lands.
        if (!_hasScrolledToBottom && entries.Count > 0)
        {
            _recyclerView.ScrollToPosition(entries.Count - 1);
            _hasScrolledToBottom = true;
        }

        PrefetchRecentNotes(entries);
    }

    private const int PrefetchCount = 3;

    // Runs after every bind - the initial cache render and every refresh - so a newer "most
    // recent 3" (e.g. a note synced in from elsewhere) gets its own prefetch too. GetOrStart
    // skips any note that's already cached from a previous round, so nothing is ever
    // double-fetched; this is why a session can end up with more than 3 notes prefetched.
    private void PrefetchRecentNotes(List<NoteCacheEntry> entries)
    {
        foreach (var entry in entries.TakeLast(PrefetchCount))
        {
            var noteId = entry.Id;
            NoteContentCache.GetOrStart(noteId, () => _graphService.GetContentAsync(noteId));
        }
    }
}