diff --git a/android/app/src/main/java/dev/plainnote/app/data/NoteRepository.kt b/android/app/src/main/java/dev/plainnote/app/data/NoteRepository.kt index 4bb2d81..7ac50a7 100644 --- a/android/app/src/main/java/dev/plainnote/app/data/NoteRepository.kt +++ b/android/app/src/main/java/dev/plainnote/app/data/NoteRepository.kt @@ -5,6 +5,7 @@ import dev.plainnote.core.FolderInfo import dev.plainnote.core.NoteApp import dev.plainnote.core.NoteContent import dev.plainnote.core.NoteSummary +import dev.plainnote.core.NoteVersionInfo import java.io.File /** @@ -45,6 +46,15 @@ interface NoteRepository { fun getNote(id: String): NoteContent + /** A note's version timeline, newest first. */ + fun history(id: String): List + + /** A note's content at a given version. */ + fun noteAt(id: String, versionId: String): NoteContent + + /** Restore a note to a past version (a new forward edit). */ + fun restoreVersion(id: String, versionId: String) + fun setTitle(id: String, title: String) fun setBody(id: String, text: String) @@ -106,6 +116,12 @@ class NativeNoteRepository(context: Context) : NoteRepository { override fun getNote(id: String): NoteContent = app.getNote(id) + override fun history(id: String): List = app.history(id) + + override fun noteAt(id: String, versionId: String): NoteContent = app.noteAt(id, versionId) + + override fun restoreVersion(id: String, versionId: String) = app.restoreVersion(id, versionId) + override fun setTitle(id: String, title: String) = app.setTitle(id, title) override fun setBody(id: String, text: String) = app.setBody(id, text) diff --git a/android/app/src/main/java/dev/plainnote/app/ui/HistoryScreen.kt b/android/app/src/main/java/dev/plainnote/app/ui/HistoryScreen.kt new file mode 100644 index 0000000..703614f --- /dev/null +++ b/android/app/src/main/java/dev/plainnote/app/ui/HistoryScreen.kt @@ -0,0 +1,183 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package dev.plainnote.app.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.plainnote.core.NoteContent +import dev.plainnote.core.NoteVersionInfo +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val HISTORY_FMT: DateTimeFormatter = + DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", Locale.FRENCH) + +/** Format a unix-millis timestamp as a local, human date. */ +fun formatVersionTime(ts: Long): String = + Instant.ofEpochMilli(ts).atZone(ZoneId.systemDefault()).format(HISTORY_FMT) + +/** The version timeline (newest first). The current version is labelled and not + * selectable; older versions open a read-only preview. */ +@Composable +fun HistoryTimeline( + versions: List, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (versions.isEmpty()) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "Aucune version antérieure.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn(modifier.fillMaxSize()) { + itemsIndexed(versions) { index, v -> + val current = index == 0 + ListItem( + headlineContent = { Text(formatVersionTime(v.timestamp)) }, + trailingContent = { + if (current) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text("Actuel") }, + colors = AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.primary, + ), + ) + } else { + Icon(Icons.Filled.ChevronRight, contentDescription = null) + } + }, + modifier = Modifier.clickable(enabled = !current) { onSelect(v.versionId) }, + ) + HorizontalDivider() + } + } +} + +/** Read-only preview of one past version, with Restore / Back. */ +@Composable +fun VersionPreview( + note: NoteContent, + onRestore: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxSize()) { + Surface(color = MaterialTheme.colorScheme.primaryContainer) { + Text( + "Version en lecture seule — le texte actuel n'est pas modifié.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.fillMaxWidth().padding(16.dp, 10.dp), + ) + } + Text( + note.title.ifEmpty { "(sans titre)" }, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp, 16.dp, 4.dp), + ) + DocView(note.text, Modifier.weight(1f).fillMaxWidth()) + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onBack) { Text("Retour") } + Button(onClick = onRestore) { Text("Restaurer cette version") } + } + } +} + +/** Full-screen history surface: timeline, then a version preview, wired to the + * ViewModel. Renders nothing until history is opened. */ +@Composable +fun HistorySheet(vm: NotesViewModel) { + val history by vm.history.collectAsState() + val preview by vm.versionPreview.collectAsState() + val versions = history ?: return + var selected by remember { mutableStateOf(null) } + + Dialog( + onDismissRequest = { selected = null; vm.closeHistory() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(Modifier.fillMaxSize()) { + val current = preview + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (current != null) "Aperçu" else "Historique") }, + navigationIcon = { + IconButton(onClick = { + if (current != null) { + selected = null + vm.closePreview() + } else { + vm.closeHistory() + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") + } + }, + ) + }, + ) { padding -> + Box(Modifier.padding(padding)) { + if (current != null) { + VersionPreview( + note = current, + onRestore = { selected?.let { vm.restoreVersion(it) } }, + onBack = { selected = null; vm.closePreview() }, + ) + } else { + HistoryTimeline( + versions = versions, + onSelect = { selected = it; vm.previewVersion(it) }, + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/dev/plainnote/app/ui/NotesScreen.kt b/android/app/src/main/java/dev/plainnote/app/ui/NotesScreen.kt index 376ef91..ea808c9 100644 --- a/android/app/src/main/java/dev/plainnote/app/ui/NotesScreen.kt +++ b/android/app/src/main/java/dev/plainnote/app/ui/NotesScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.DriveFileMove import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Label @@ -769,6 +770,9 @@ private fun NoteEditor(vm: NotesViewModel, note: NoteContent) { IconButton(onClick = { editing = true }) { Icon(Icons.Filled.Edit, contentDescription = "Éditer") } + IconButton(onClick = { vm.openHistory() }) { + Icon(Icons.Filled.History, contentDescription = "Historique") + } } IconButton(onClick = { vm.delete(note.id) }) { Icon(Icons.Filled.Delete, contentDescription = "Supprimer") @@ -847,6 +851,8 @@ private fun NoteEditor(vm: NotesViewModel, note: NoteContent) { onAdd = { t -> tags = tags + t; vm.addTag(note.id, t); showTag = false }, ) } + + HistorySheet(vm) } @Composable diff --git a/android/app/src/main/java/dev/plainnote/app/ui/NotesViewModel.kt b/android/app/src/main/java/dev/plainnote/app/ui/NotesViewModel.kt index f861083..25b7ce6 100644 --- a/android/app/src/main/java/dev/plainnote/app/ui/NotesViewModel.kt +++ b/android/app/src/main/java/dev/plainnote/app/ui/NotesViewModel.kt @@ -8,6 +8,7 @@ import dev.plainnote.app.data.NoteRepository import dev.plainnote.core.FolderInfo import dev.plainnote.core.NoteContent import dev.plainnote.core.NoteSummary +import dev.plainnote.core.NoteVersionInfo import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -307,6 +308,56 @@ class NotesViewModel( } } + // --- version history (see docs/design/note-history.md) --- + + /** Non-null while the history timeline is open (newest first). */ + private val _history = MutableStateFlow?>(null) + val history = _history.asStateFlow() + + /** Non-null while previewing a past version (read-only). */ + private val _versionPreview = MutableStateFlow(null) + val versionPreview = _versionPreview.asStateFlow() + + fun openHistory() = viewModelScope.launch(io) { + val id = _editing.value?.id ?: return@launch + try { + _history.value = repo.history(id) + } catch (e: Exception) { + report(e) + } + } + + fun closeHistory() { + _history.value = null + _versionPreview.value = null + } + + fun previewVersion(versionId: String) = viewModelScope.launch(io) { + val id = _editing.value?.id ?: return@launch + try { + _versionPreview.value = repo.noteAt(id, versionId) + } catch (e: Exception) { + report(e) + } + } + + fun closePreview() { + _versionPreview.value = null + } + + fun restoreVersion(versionId: String) = viewModelScope.launch(io) { + val id = _editing.value?.id ?: return@launch + try { + repo.restoreVersion(id, versionId) + _editing.value = repo.getNote(id) // refresh the open editor + _versionPreview.value = null + _history.value = null + reloadNotes() + } catch (e: Exception) { + report(e) + } + } + fun clearStatus() { _status.value = null } diff --git a/android/app/src/test/java/dev/plainnote/app/ui/HistoryScreenshotTest.kt b/android/app/src/test/java/dev/plainnote/app/ui/HistoryScreenshotTest.kt new file mode 100644 index 0000000..1d1d06e --- /dev/null +++ b/android/app/src/test/java/dev/plainnote/app/ui/HistoryScreenshotTest.kt @@ -0,0 +1,71 @@ +package dev.plainnote.app.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import dev.plainnote.core.NoteVersionInfo +import java.util.TimeZone +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Screenshot regression (L3): render the read-only [HistoryTimeline] through + * Robolectric and diff against golden PNGs. The timezone is pinned to UTC so the + * formatted dates are deterministic across machines. + * + * Record: `./gradlew :app:recordRoborazziDebug`. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = RobolectricDeviceQualifiers.Pixel5) +class HistoryScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + private val sample = listOf( + NoteVersionInfo("v5", 1_712_150_640_000L), + NoteVersionInfo("v4", 1_712_064_240_000L), + NoteVersionInfo("v3", 1_711_900_000_000L), + NoteVersionInfo("v2", 1_711_000_000_000L), + NoteVersionInfo("v1", 1_709_460_000_000L), + ) + + @Before + fun pinTimezone() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun capture(dark: Boolean, path: String) { + compose.setContent { + MaterialTheme(colorScheme = if (dark) DarkColors else LightColors) { + Surface { + HistoryTimeline( + sample, + onSelect = {}, + Modifier.fillMaxWidth().height(360.dp), + ) + } + } + } + compose.onRoot().captureRoboImage(path) + } + + @Test + fun history_light() = capture(dark = false, path = "src/test/roborazzi/history_light.png") + + @Test + fun history_dark() = capture(dark = true, path = "src/test/roborazzi/history_dark.png") +} diff --git a/android/app/src/test/java/dev/plainnote/app/ui/NotesViewModelTest.kt b/android/app/src/test/java/dev/plainnote/app/ui/NotesViewModelTest.kt index cd85778..5f6d319 100644 --- a/android/app/src/test/java/dev/plainnote/app/ui/NotesViewModelTest.kt +++ b/android/app/src/test/java/dev/plainnote/app/ui/NotesViewModelTest.kt @@ -7,6 +7,7 @@ import dev.plainnote.app.data.NoteRepository import dev.plainnote.core.FolderInfo import dev.plainnote.core.NoteContent import dev.plainnote.core.NoteSummary +import dev.plainnote.core.NoteVersionInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -16,6 +17,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -33,6 +35,8 @@ private class FakeRepo( var syncCount = 0 var pairCount = 0 var setBodyCount = 0 + var restoreCount = 0 + val versions = mutableListOf() override fun listNotes(folder: String?): List = emptyList() override fun search(query: String): List = emptyList() @@ -50,6 +54,14 @@ private class FakeRepo( override fun getNote(id: String): NoteContent = NoteContent(id, "", "", "", emptyList(), false) + override fun history(id: String): List = versions.toList() + override fun noteAt(id: String, versionId: String): NoteContent = + NoteContent(id, "titre@$versionId", "corps@$versionId", "", emptyList(), false) + + override fun restoreVersion(id: String, versionId: String) { + restoreCount++ + } + override fun setTitle(id: String, title: String) {} override fun setBody(id: String, text: String) { setBodyCount++ @@ -125,6 +137,30 @@ class NotesViewModelTest { assertEquals(1, repo.pairCount) } + @Test + fun history_preview_and_restore() = runTest(dispatcher) { + val repo = FakeRepo() + repo.versions.add(NoteVersionInfo("v-new", 2000)) + repo.versions.add(NoteVersionInfo("v-old", 1000)) + val vm = viewModel(repo) + vm.open("n") + advanceUntilIdle() + + vm.openHistory() + advanceUntilIdle() + assertEquals(2, vm.history.value?.size) + + vm.previewVersion("v-old") + advanceUntilIdle() + assertEquals("n", vm.versionPreview.value?.id) + + vm.restoreVersion("v-old") + advanceUntilIdle() + assertEquals(1, repo.restoreCount) + assertNull("history closes after restore", vm.history.value) + assertNull("preview closes after restore", vm.versionPreview.value) + } + @Test fun rapid_edits_debounce_to_a_single_auto_sync() = runTest(dispatcher) { val repo = FakeRepo(enrolled = true) diff --git a/android/app/src/test/roborazzi/history_dark.png b/android/app/src/test/roborazzi/history_dark.png new file mode 100644 index 0000000..26c174e Binary files /dev/null and b/android/app/src/test/roborazzi/history_dark.png differ diff --git a/android/app/src/test/roborazzi/history_light.png b/android/app/src/test/roborazzi/history_light.png new file mode 100644 index 0000000..8d3bf6f Binary files /dev/null and b/android/app/src/test/roborazzi/history_light.png differ diff --git a/mobile/src/lib.rs b/mobile/src/lib.rs index 75294dc..6114e1a 100644 --- a/mobile/src/lib.rs +++ b/mobile/src/lib.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::sync::Arc; -use note_core::NoteMeta; +use note_core::{Note, NoteMeta}; use plain_note_client::commands::{self, FolderRow}; use plain_note_client::store::{self, LocalStore}; use plain_note_client::{config, remote}; @@ -41,6 +41,13 @@ pub struct NoteContent { pub pinned: bool, } +/// One entry in a note's history timeline (opaque id + when it was reached). +#[derive(Debug, uniffi::Record)] +pub struct NoteVersionInfo { + pub version_id: String, + pub timestamp: i64, +} + /// A folder with its resolved display path. #[derive(Debug, uniffi::Record)] pub struct FolderInfo { @@ -122,6 +129,17 @@ fn summary(m: NoteMeta) -> NoteSummary { } } +fn content(n: Note) -> NoteContent { + NoteContent { + id: n.id.as_str().to_string(), + title: n.title, + text: n.text, + folder: n.folder, + tags: n.tags, + pinned: n.pinned, + } +} + /// The mobile app handle: a local store, the enrollment config path, and the /// operations over them. #[derive(uniffi::Object)] @@ -171,14 +189,31 @@ impl NoteApp { /// Fetch a note's full content by id. pub fn get_note(&self, id: String) -> Result { let n = commands::get(&self.store, &id).map_err(map)?; - Ok(NoteContent { - id: n.id.as_str().to_string(), - title: n.title, - text: n.text, - folder: n.folder, - tags: n.tags, - pinned: n.pinned, - }) + Ok(content(n)) + } + + /// The note's version timeline, newest first. + pub fn history(&self, id: String) -> Result> { + Ok(commands::history(&self.store, &id) + .map_err(map)? + .into_iter() + .map(|v| NoteVersionInfo { + version_id: v.version_id, + timestamp: v.timestamp, + }) + .collect()) + } + + /// The note's content at a given version. + pub fn note_at(&self, id: String, version_id: String) -> Result { + let n = commands::note_at(&self.store, &id, &version_id).map_err(map)?; + Ok(content(n)) + } + + /// Restore a note to a past version (a new forward edit). + pub fn restore_version(&self, id: String, version_id: String) -> Result<()> { + commands::restore_version(&self.store, now(), &id, &version_id).map_err(map)?; + Ok(()) } pub fn set_title(&self, id: String, title: String) -> Result<()> { @@ -470,6 +505,19 @@ mod tests { assert!(app.list_trashed().unwrap().is_empty()); } + #[test] + fn history_note_at_and_restore_via_facade() { + let app = temp_app(); + let id = app.create_note().unwrap(); + app.set_title(id.clone(), "V1".into()).unwrap(); + let h = app.history(id.clone()).unwrap(); + assert!(!h.is_empty()); + let oldest = h.last().unwrap().version_id.clone(); + let snap = app.note_at(id.clone(), oldest.clone()).unwrap(); + assert_eq!(snap.id, id); + app.restore_version(id.clone(), oldest).unwrap(); + } + #[test] fn folder_rename_move_and_delete() { let app = temp_app();