📄 src/Activities/NoteViewActivity.cs
using Android.Content;
using Android.Views;
using AndroidX.Activity;
using AndroidX.AppCompat.App;
using Google.Android.Material.ProgressIndicator;
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 NoteViewActivity : AppCompatActivity
{
    public const string ExtraNoteId = "note_id";
    public const string ExtraNoteName = "note_name";
    public const string ExtraNotePreview = "note_preview";
    public const string ExtraNoteETag = "note_etag";
    public const string ExtraNoteLastModifiedUtc = "note_last_modified_utc";
    public const string ExtraNoteSize = "note_size";
    public const string ExtraInitialContent = "initial_content";

    private EditText _contentView = null!;
    private LinearProgressIndicator _progressBar = null!;
    private GraphService _graphService = null!;

    private string? _noteId;
    private string _noteName = "";
    private string _noteETag = "";
    private DateTimeOffset? _lastModifiedUtc;
    private long? _size;
    private string _currentPreview = "";

    private bool _isDirty;
    private bool _suppressDirtyTracking;
    private bool _savedAtLeastOnce;

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

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

        var toolbar = FindViewById<Toolbar>(Resource.Id.note_view_toolbar)!;
        SetSupportActionBar(toolbar);
        SupportActionBar?.SetDisplayHomeAsUpEnabled(true);

        _noteId = Intent?.GetStringExtra(ExtraNoteId);
        _noteName = Intent?.GetStringExtra(ExtraNoteName) ?? "";
        toolbar.Title = _noteName;

        _contentView = FindViewById<EditText>(Resource.Id.note_view_content)!;
        _progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_view_progress)!;

        if (_noteId != null)
        {
            // For an existing note, the tapped-from-list entry's fields are the starting point;
            // they're only overwritten once we actually load or save fresher data below.
            var incoming = NoteResultIntent.TryGet(Intent);
            _currentPreview = incoming?.Preview ?? "";
            _noteETag = incoming?.ETag ?? "";
            _lastModifiedUtc = incoming?.LastModifiedUtc;
            _size = incoming?.Size;
            SetContentTextSuppressed(_currentPreview);
        }
        else
        {
            // A brand new note starts pre-populated with a frontmatter block carried over from
            // the previous note (see NoteListActivity.BuildInitialContentAsync) - the cursor
            // lands past it so the user can start typing the body immediately.
            var initialContent = Intent?.GetStringExtra(ExtraInitialContent) ?? "";
            SetContentTextSuppressed(initialContent);
            _contentView.SetSelection(initialContent.Length);
        }

        _contentView.TextChanged += (_, _) =>
        {
            if (!_suppressDirtyTracking)
                _isDirty = true;
        };

        _graphService = new GraphService(this);

        // Android 16 (targetSdk 36) enables predictive back by default, which stops the
        // system from calling the deprecated Activity.OnBackPressed() at all - the
        // OnBackPressedDispatcher/OnBackPressedCallback API is now the only reliable hook.
        OnBackPressedDispatcher.AddCallback(
            this,
            new DiscardCheckBackPressedCallback(FinishWithDiscardCheck)
        );

        if (_noteId != null)
        {
            var noteId = _noteId;
            // GetOrStart reuses an already-running prefetch or a previous open's cached fetch
            // instead of starting a second one - whichever's already in flight wins the race.
            var contentTask = NoteContentCache.GetOrStart(
                noteId,
                () => _graphService.GetContentAsync(noteId)
            );
            _ = LoadContentAsync(contentTask);
        }
    }

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

    public override bool OnOptionsItemSelected(IMenuItem item)
    {
        if (item.ItemId == Resource.Id.action_save)
        {
            _ = SaveAsync();
            return true;
        }
        return base.OnOptionsItemSelected(item);
    }

    public override bool OnSupportNavigateUp()
    {
        FinishWithDiscardCheck();
        return true;
    }

    private void FinishWithDiscardCheck()
    {
        if (!_isDirty)
        {
            FinishWithResult();
            return;
        }

        new AlertDialog.Builder(this)!
            .SetTitle(Resource.String.discard_changes_title)!
            .SetMessage(Resource.String.discard_changes_message)!
            .SetPositiveButton(Resource.String.save_note, (_, _) => _ = SaveAndFinishAsync())!
            .SetNeutralButton(Resource.String.discard, (_, _) => FinishWithResult())!
            .SetNegativeButton(Resource.String.cancel, (_, _) => { })!
            .Show();
    }

    private async Task SaveAndFinishAsync()
    {
        await SaveAsync();
        // SaveAsync only clears _isDirty on success - if it's still set, the save failed (a
        // Toast was already shown) and the note shouldn't be closed out from under the user.
        if (!_isDirty)
            FinishWithResult();
    }

    private void FinishWithResult()
    {
        // Even a note that was only viewed (never edited) usually has a better preview now
        // than the possibly-stale/truncated one the list had cached - report it back either way.
        if (_noteId != null)
        {
            var data = new Intent();
            NoteResultIntent.Put(
                data,
                new NoteCacheEntry(
                    _noteId,
                    _noteName,
                    _noteETag,
                    _lastModifiedUtc,
                    _currentPreview,
                    _size
                )
            );
            SetResult(Result.Ok, data);
        }

        Finish();
    }

    private async Task LoadContentAsync(Task<string> contentTask)
    {
        _progressBar.Visibility = ViewStates.Visible;
        try
        {
            var content = await contentTask;
            // Don't clobber the user's typing, or a save that already completed, if this
            // load was still in flight when either of those happened.
            if (!_isDirty && !_savedAtLeastOnce)
            {
                SetContentTextSuppressed(content);
                _currentPreview = NotePreviewBuilder.Build(content);
            }
        }
        catch (Exception ex)
        {
            Toast.MakeText(this, ex.Message, ToastLength.Long)?.Show();
            // Leave the preview text in place as a partial fallback rather than blanking it.
        }
        finally
        {
            _progressBar.Visibility = ViewStates.Invisible;
        }
    }

    private async Task SaveAsync()
    {
        if (!_isDirty)
            return;

        var content = _contentView.Text ?? "";
        _progressBar.Visibility = ViewStates.Visible;
        try
        {
            if (_noteId is null)
            {
                var folderId = AppSettings.RootFolderId;
                if (folderId is null)
                    throw new InvalidOperationException("No notes folder configured.");

                var created = await _graphService.CreateNoteAsync(
                    folderId,
                    _noteName,
                    content,
                    NotePreviewBuilder.Build(content)
                );
                _noteId = created.Id;
                _noteETag = created.ETag;
                _lastModifiedUtc = created.LastModifiedUtc;
                _size = created.Size;
            }
            else
            {
                var (eTag, lastModified, size) = await _graphService.PutContentAsync(
                    _noteId,
                    content
                );
                _noteETag = eTag;
                _lastModifiedUtc = lastModified;
                _size = size;
            }

            NoteContentCache.Set(_noteId, content);
            _currentPreview = NotePreviewBuilder.Build(content);
            _isDirty = false;
            _savedAtLeastOnce = true;
        }
        catch (Exception ex)
        {
            var message = string.Format(GetString(Resource.String.save_failed), ex.Message);
            Toast.MakeText(this, message, ToastLength.Long)?.Show();
        }
        finally
        {
            _progressBar.Visibility = ViewStates.Invisible;
        }
    }

    private void SetContentTextSuppressed(string text)
    {
        _suppressDirtyTracking = true;
        _contentView.Text = text;
        _suppressDirtyTracking = false;
    }

    private sealed class DiscardCheckBackPressedCallback(Action onBackPressed)
        : OnBackPressedCallback(true)
    {
        public override void HandleOnBackPressed() => onBackPressed();
    }
}