Commit: b7cef76
Parent: e2be544

Add note editing, new note creation, and a save/discard flow

Mårten Åsberg committed on 2026-08-01 at 21:54
Notes are now editable in place: the read-only viewer becomes a
monospaced, multiline EditText shared by viewing, editing, and
creation, with a save action in the toolbar and a discard-changes
dialog guarding navigation away from unsaved edits. A "new note" FAB
on the list creates a timestamped file and reuses the same editor for
its first save. GraphService gains PutContentAsync and CreateNoteAsync
(Graph's SDK has no colon-path create endpoint, so creation is an
empty-file POST followed by a content PUT), and previews now strip a
leading YAML frontmatter block and get passed back to the list on
every close, not just on save, via a shared NoteResultIntent contract.

Registers an explicit OnBackPressedCallback via OnBackPressedDispatcher
instead of overriding the deprecated Activity.OnBackPressed(), since
targeting SDK 36 makes Android enable predictive back by default and
that override stops being called under it - without this, the
discard-changes dialog silently never ran on back press.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/Activities/FolderPickerActivity.cs +1 -1
diff --git a/src/Activities/FolderPickerActivity.cs b/src/Activities/FolderPickerActivity.cs
index ba1792a..799b4a5 100644
@@ -105,7 +105,7 @@ public class FolderPickerActivity : AppCompatActivity
}
finally
{
_progressBar.Visibility = ViewStates.Gone;
_progressBar.Visibility = ViewStates.Invisible;
}
}
src/Activities/NoteListActivity.cs +43 -5
diff --git a/src/Activities/NoteListActivity.cs b/src/Activities/NoteListActivity.cs
index cb231c4..84d5011 100644
@@ -2,6 +2,7 @@ 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;
@@ -15,6 +16,8 @@ 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!;
@@ -39,6 +42,9 @@ public class NoteListActivity : AppCompatActivity
_progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_refresh_progress)!;
FindViewById<FloatingActionButton>(Resource.Id.new_note_fab)!.Click += (_, _) =>
OnNewNoteClicked();
_graphService = new GraphService(this);
// Cache renders immediately, scrolled to the bottom, before any network call.
@@ -92,7 +98,7 @@ public class NoteListActivity : AppCompatActivity
}
finally
{
_progressBar.Visibility = ViewStates.Gone;
_progressBar.Visibility = ViewStates.Invisible;
}
}
@@ -112,13 +118,45 @@ public class NoteListActivity : AppCompatActivity
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 void OnNewNoteClicked()
{
var fileName = DateTime.Now.ToString("yyyy-MM-ddTHHmmss") + ".md";
var intent = new Intent(this, typeof(NoteViewActivity));
intent.PutExtra(NoteViewActivity.ExtraNoteName, fileName);
StartActivityForResult(intent, EditNoteRequestCode);
}
private void OnNoteTapped(NoteCacheEntry entry)
{
var intent = new Intent(this, typeof(NoteViewActivity));
intent.PutExtra(NoteViewActivity.ExtraNoteId, entry.Id);
intent.PutExtra(NoteViewActivity.ExtraNoteName, entry.Name);
intent.PutExtra(NoteViewActivity.ExtraNotePreview, entry.Preview);
StartActivity(intent);
NoteResultIntent.Put(intent, entry);
StartActivityForResult(intent, EditNoteRequestCode);
}
private void BindItems(List<NoteCacheEntry> entries)
src/Activities/NoteResultIntent.cs +44 -0
diff --git a/src/Activities/NoteResultIntent.cs b/src/Activities/NoteResultIntent.cs
new file mode 100644
index 0000000..4904dce
@@ -0,0 +1,44 @@
using System.Globalization;
using Android.Content;
using NotesApp.Models;
namespace NotesApp.Activities;
// The extras contract shared by NoteListActivity <-> NoteViewActivity: launching an existing
// note passes its current cached fields in, and closing (whether or not anything was edited)
// passes the freshest known fields back out, so the list can patch its cache without a
// network round trip either way.
internal static class NoteResultIntent
{
public static void Put(Intent intent, NoteCacheEntry entry)
{
intent.PutExtra(NoteViewActivity.ExtraNoteId, entry.Id);
intent.PutExtra(NoteViewActivity.ExtraNoteName, entry.Name);
intent.PutExtra(NoteViewActivity.ExtraNotePreview, entry.Preview);
intent.PutExtra(NoteViewActivity.ExtraNoteETag, entry.ETag);
intent.PutExtra(
NoteViewActivity.ExtraNoteLastModifiedUtc,
entry.LastModifiedUtc?.ToString("O") ?? ""
);
intent.PutExtra(NoteViewActivity.ExtraNoteSize, entry.Size ?? -1L);
}
public static NoteCacheEntry? TryGet(Intent? intent)
{
var id = intent?.GetStringExtra(NoteViewActivity.ExtraNoteId);
var name = intent?.GetStringExtra(NoteViewActivity.ExtraNoteName);
if (id is null || name is null)
return null;
var preview = intent?.GetStringExtra(NoteViewActivity.ExtraNotePreview) ?? "";
var eTag = intent?.GetStringExtra(NoteViewActivity.ExtraNoteETag) ?? "";
var lastModifiedRaw = intent?.GetStringExtra(NoteViewActivity.ExtraNoteLastModifiedUtc);
var lastModified = string.IsNullOrEmpty(lastModifiedRaw)
? (DateTimeOffset?)null
: DateTimeOffset.Parse(lastModifiedRaw, null, DateTimeStyles.RoundtripKind);
var sizeRaw = intent?.GetLongExtra(NoteViewActivity.ExtraNoteSize, -1) ?? -1;
var size = sizeRaw < 0 ? (long?)null : sizeRaw;
return new NoteCacheEntry(id, name, eTag, lastModified, preview, size);
}
}
src/Activities/NoteViewActivity.cs +216 -11
diff --git a/src/Activities/NoteViewActivity.cs b/src/Activities/NoteViewActivity.cs
index 286ec9b..470e83b 100644
@@ -1,8 +1,13 @@
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;
@@ -13,11 +18,25 @@ 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";
private TextView _contentView = null!;
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);
@@ -30,32 +49,117 @@ public class NoteViewActivity : AppCompatActivity
SetSupportActionBar(toolbar);
SupportActionBar?.SetDisplayHomeAsUpEnabled(true);
var noteId = Intent?.GetStringExtra(ExtraNoteId);
toolbar.Title =
Intent?.GetStringExtra(ExtraNoteName) ?? GetString(Resource.String.app_name);
_noteId = Intent?.GetStringExtra(ExtraNoteId);
_noteName = Intent?.GetStringExtra(ExtraNoteName) ?? "";
toolbar.Title = _noteName;
_contentView = FindViewById<TextView>(Resource.Id.note_view_content)!;
// 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;
_contentView = FindViewById<EditText>(Resource.Id.note_view_content)!;
_progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_view_progress)!;
_contentView.Text = Intent?.GetStringExtra(ExtraNotePreview) ?? "";
SetContentTextSuppressed(_currentPreview);
_contentView.TextChanged += (_, _) =>
{
if (!_suppressDirtyTracking)
_isDirty = true;
};
_graphService = new GraphService(this);
if (noteId != null)
_ = LoadContentAsync(noteId);
// 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)
_ = LoadContentAsync(_noteId);
}
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()
{
Finish();
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.discard, (_, _) => FinishWithResult())!
.SetNegativeButton(Resource.String.cancel, (_, _) => { })!
.Show();
}
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(string noteId)
{
_progressBar.Visibility = ViewStates.Visible;
try
{
_contentView.Text = await _graphService.GetContentAsync(noteId);
var content = await _graphService.GetContentAsync(noteId);
// 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 = BuildPreview(content);
}
}
catch (Exception ex)
{
@@ -64,7 +168,108 @@ public class NoteViewActivity : AppCompatActivity
}
finally
{
_progressBar.Visibility = ViewStates.Gone;
_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,
BuildPreview(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;
}
_currentPreview = BuildPreview(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 static string BuildPreview(string content)
{
var withoutFrontmatter = StripFrontmatter(content);
const int maxLength = 300;
var trimmed =
withoutFrontmatter.Length > maxLength
? withoutFrontmatter[..maxLength]
: withoutFrontmatter;
var lastNewline = trimmed.LastIndexOf('\n');
return lastNewline > 0 ? trimmed[..lastNewline] : trimmed;
}
private static string StripFrontmatter(string content)
{
var lines = content.Split(
["\n", "\r\n"],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
);
if (lines is [])
return content;
var firstLine = lines[0];
if (firstLine.Any(c => c != '-'))
return content;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Length == firstLine.Length && line.All(c => c == '-'))
return string.Join('\n', lines[(i + 1)..]);
}
return content;
}
private sealed class DiscardCheckBackPressedCallback(Action onBackPressed)
: OnBackPressedCallback(true)
{
public override void HandleOnBackPressed() => onBackPressed();
}
}
src/AndroidManifest.xml +1 -0
diff --git a/src/AndroidManifest.xml b/src/AndroidManifest.xml
index 48d7d08..e58975d 100644
@@ -3,6 +3,7 @@
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="36" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/appicon"
android:label="@string/app_name"
android:roundIcon="@mipmap/appicon_round"
src/Graph/GraphService.cs +40 -0
diff --git a/src/Graph/GraphService.cs b/src/Graph/GraphService.cs
index 099748e..5f4625d 100644
@@ -205,4 +205,44 @@ public class GraphService
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync(ct);
}
public async Task<(string ETag, DateTimeOffset? LastModifiedUtc, long? Size)> PutContentAsync(
string itemId,
string content,
CancellationToken ct = default
)
{
var driveId = await GetDriveIdAsync(ct);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
var updated = await _client
.Drives[driveId]
.Items[itemId]
.Content.PutAsync(stream, cfg => cfg.Headers.Add("Content-Type", ["text/plain"]), ct);
return (updated?.ETag ?? "", updated?.LastModifiedDateTime, updated?.Size);
}
public async Task<NoteCacheEntry> CreateNoteAsync(
string folderId,
string fileName,
string content,
string preview,
CancellationToken ct = default
)
{
var driveId = await GetDriveIdAsync(ct);
var created = await _client
.Drives[driveId]
.Items[folderId]
.Children.PostAsync(
new DriveItem { Name = fileName, File = new FileObject() },
cancellationToken: ct
);
var newId =
created?.Id
?? throw new InvalidOperationException("Create failed: no item id returned.");
var (eTag, lastModified, size) = await PutContentAsync(newId, content, ct);
return new NoteCacheEntry(newId, fileName, eTag, lastModified, preview, size);
}
}
src/Resources/drawable/ic_add_24.xml +10 -0
diff --git a/src/Resources/drawable/ic_add_24.xml b/src/Resources/drawable/ic_add_24.xml
new file mode 100644
index 0000000..a1f12c0
@@ -0,0 +1,10 @@
<?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="#FF000000" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>
src/Resources/drawable/ic_save_24.xml +14 -0
diff --git a/src/Resources/drawable/ic_save_24.xml b/src/Resources/drawable/ic_save_24.xml
new file mode 100644
index 0000000..bd23d70
@@ -0,0 +1,14 @@
<?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"
android:tint="?attr/colorOnSurface"
>
<path
android:fillColor="#FF000000"
android:pathData="M17,3H5C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V7L17,3zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9H5V5h10v4z"
/>
</vector>
src/Resources/layout/activity_folder_picker.xml +1 -1
diff --git a/src/Resources/layout/activity_folder_picker.xml b/src/Resources/layout/activity_folder_picker.xml
index 64242c5..1218613 100644
@@ -23,7 +23,7 @@
android:id="@+id/folder_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:visibility="invisible"
android:indeterminate="true"
/>
src/Resources/layout/activity_note_list.xml +19 -4
diff --git a/src/Resources/layout/activity_note_list.xml b/src/Resources/layout/activity_note_list.xml
index 4643ba8..a01e65b 100644
@@ -14,18 +14,33 @@
app:title="@string/app_name"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/note_recycler_view"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
/>
>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/note_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/new_note_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/new_note_content_description"
app:srcCompat="@drawable/ic_add_24"
/>
</FrameLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/note_refresh_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:visibility="invisible"
android:indeterminate="true"
/>
</LinearLayout>
src/Resources/layout/activity_note_view.xml +12 -10
diff --git a/src/Resources/layout/activity_note_view.xml b/src/Resources/layout/activity_note_view.xml
index 2c7fecd..e34555d 100644
@@ -12,26 +12,28 @@
android:layout_height="?attr/actionBarSize"
/>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/note_view_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible"
android:indeterminate="true"
/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
>
<TextView
<EditText
android:id="@+id/note_view_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:fontFamily="monospace"
android:textIsSelectable="true"
android:inputType="textMultiLine"
android:gravity="top|start"
android:background="@android:color/transparent"
/>
</ScrollView>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/note_view_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
/>
</LinearLayout>
src/Resources/menu/menu_note_edit.xml +12 -0
diff --git a/src/Resources/menu/menu_note_edit.xml b/src/Resources/menu/menu_note_edit.xml
new file mode 100644
index 0000000..a45c124
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
>
<item
android:id="@+id/action_save"
android:title="@string/save_note"
android:icon="@drawable/ic_save_24"
app:showAsAction="ifRoom"
/>
</menu>
src/Resources/values/strings.xml +8 -0
diff --git a/src/Resources/values/strings.xml b/src/Resources/values/strings.xml
index 7eb5b9f..53c38ae 100644
@@ -16,4 +16,12 @@
<string name="no_folder_selected">No notes folder selected</string>
<string name="folder_selected_format">Notes folder: {0}</string>
<string name="choose_folder_button">Choose OneDrive folder</string>
<string name="save_note">Save</string>
<string name="new_note_content_description">New note</string>
<string name="discard_changes_title">Discard changes?</string>
<string name="discard_changes_message">This note has unsaved changes.</string>
<string name="discard">Discard</string>
<string name="cancel">Cancel</string>
<string name="save_failed">Save failed: {0}</string>
</resources>