Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions android/app/src/main/java/dev/plainnote/app/data/NoteRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -45,6 +46,15 @@ interface NoteRepository {

fun getNote(id: String): NoteContent

/** A note's version timeline, newest first. */
fun history(id: String): List<NoteVersionInfo>

/** 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)
Expand Down Expand Up @@ -106,6 +116,12 @@ class NativeNoteRepository(context: Context) : NoteRepository {

override fun getNote(id: String): NoteContent = app.getNote(id)

override fun history(id: String): List<NoteVersionInfo> = 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)
Expand Down
183 changes: 183 additions & 0 deletions android/app/src/main/java/dev/plainnote/app/ui/HistoryScreen.kt
Original file line number Diff line number Diff line change
@@ -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<NoteVersionInfo>,
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<String?>(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) },
)
}
}
}
}
}
}
6 changes: 6 additions & 0 deletions android/app/src/main/java/dev/plainnote/app/ui/NotesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions android/app/src/main/java/dev/plainnote/app/ui/NotesViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<NoteVersionInfo>?>(null)
val history = _history.asStateFlow()

/** Non-null while previewing a past version (read-only). */
private val _versionPreview = MutableStateFlow<NoteContent?>(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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading