From 2a176d6aa0f0a4364fdd36c943a9b0f3967b3c47 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:34:27 -0400 Subject: [PATCH] feat(ui): show member created dates when the system opts in Client side of the new show_member_created_date system setting. Off by default, matching the backend: some systems want to see when each member was added, others find it noise. The toggle lives in Settings > Profile under a Display heading, saved with the rest of that form rather than applying instantly, which is how the web client does it too. Saving writes the updated system through to the local cache so the profile picks the change up immediately instead of waiting for a Home refresh. On the profile itself the date is a row in the details card, next to birthday and privacy. Web shows it as muted text under the name, but on Android that is where facts like this already live. Rendered date-only in the resolved display timezone: the hour a member was added is noise on a profile. The flag is read from the cached system rather than its own request, because this screen's loads are sequential and a fifth call would add a round-trip to every profile open for a setting that changes about never. A missing field (old cached payload, or a server predating the setting) reads as off, so the date stays hidden rather than appearing unasked. Wire-contract tests cover the snake_case name in both directions, the absent-means-off default, and that switching the toggle back off still reaches the wire instead of being dropped as a null. --- .../systems/lupine/sheaf/data/model/Models.kt | 6 +++ .../lupine/sheaf/ui/members/MembersScreen.kt | 30 +++++++++++++ .../sheaf/ui/members/MembersViewModel.kt | 31 +++++++++++++- .../sheaf/ui/settings/SettingsScreen.kt | 28 +++++++++++++ .../sheaf/ui/settings/SystemEditViewModel.kt | 12 +++++- .../sheaf/data/model/ModelContractsTest.kt | 42 +++++++++++++++++++ 6 files changed, 146 insertions(+), 3 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index f3ba1e2..d02c7b7 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -251,6 +251,11 @@ data class SystemRead( // (each device renders in its own local clock). Synced across the account's // devices; a per-device override can shadow it locally. See [resolveDisplayZone]. val timezone: String? = null, + // Display preference: show each member's created date on their profile. + // Opt-in per system (default false, matching the backend) - some systems + // want it, others find it noise. Purely a display gate; MemberRead.createdAt + // is always present regardless. + @Json(name = "show_member_created_date") val showMemberCreatedDate: Boolean = false, @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, ) @@ -274,6 +279,7 @@ data class SystemUpdate( val color: String? = null, val privacy: String? = null, val note: String? = null, + @Json(name = "show_member_created_date") val showMemberCreatedDate: Boolean? = null, ) // ── System Safety ───────────────────────────────────────────────────────────── diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index b7460fc..92b020e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -1085,6 +1085,27 @@ fun MemberProfileScreen( leadingContent = { Icon(Icons.Default.Lock, contentDescription = null) }, colors = itemColors, ) + + // Created date, only when the system opted in (see + // Settings > Profile). Web puts this as muted text under + // the name; on Android the profile keeps facts like this + // in the details card, next to birthday and privacy. + if (state.showCreatedDate) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ListItem( + headlineContent = { Text("Created") }, + trailingContent = { + Text( + formatCreatedDate(member.createdAt, LocalDisplayTimeZone.current), + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon(Icons.Default.CalendarToday, contentDescription = null) + }, + colors = itemColors, + ) + } } // Custom-field values. Only render when the viewer @@ -1460,6 +1481,15 @@ private fun formatRevisionDate(iso: String, zone: ZoneId): String = runCatching OffsetDateTime.parse(iso).atZoneSameInstant(zone).toLocalDateTime().format(revisionDateFormatter) }.getOrDefault(iso) +// Date only, no clock: "when was this member added" is a date-scale fact, and +// the hour it happened is noise on a profile. +private val createdDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy") + +private fun formatCreatedDate(iso: String, zone: ZoneId): String = runCatching { + OffsetDateTime.parse(iso).atZoneSameInstant(zone).toLocalDate().format(createdDateFormatter) +}.getOrDefault(iso) + private fun formatBirthday(value: String): String? { val full = Regex("(\\d{4})-(\\d{2})-(\\d{2})").matchEntire(value) val yearless = Regex("--(\\d{2})-(\\d{2})").matchEntire(value) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index c6ee135..57179a0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -697,6 +697,9 @@ data class MemberProfileUiState( * the map are unset (display as em-dash). Fields the viewer isn't * allowed to see are absent because the server omitted them. */ val customFieldValues: Map = emptyMap(), + /** System display preference: show this member's created date. Off unless + * the system opted in, and off if we couldn't read the system at all. */ + val showCreatedDate: Boolean = false, ) @HiltViewModel @@ -732,20 +735,36 @@ class MemberProfileViewModel @Inject constructor( val vals = runCatching { api.getMemberFieldValues(memberId) } .getOrDefault(emptyList()) .associate { it.fieldId to it.value } + // Display preference comes from the cached system rather than + // its own request: this screen's loads are sequential, so a + // fifth call would add a round-trip to every profile open for + // a setting that changes about never. Home refreshes the + // cache on every resume and the system editor writes through + // on save, so it doesn't go stale in practice. + val showCreated = cache.getSystem()?.showMemberCreatedDate ?: false _state.update { it.copy( member = member, currentFronts = fronts, customFields = defs, customFieldValues = vals, + showCreatedDate = showCreated, isLoading = false, ) } }.onFailure { e -> val cached = cache.getMember(memberId) val fronts = cache.getFronts() ?: emptyList() + val showCreated = cache.getSystem()?.showMemberCreatedDate ?: false if (cached != null) { - _state.update { it.copy(member = cached, currentFronts = fronts, isLoading = false) } + _state.update { + it.copy( + member = cached, + currentFronts = fronts, + showCreatedDate = showCreated, + isLoading = false, + ) + } } else { _state.update { it.copy(isLoading = false, error = e.toUserMessage()) } } @@ -753,8 +772,16 @@ class MemberProfileViewModel @Inject constructor( } else { val cached = cache.getMember(memberId) val fronts = cache.getFronts() ?: emptyList() + val showCreated = cache.getSystem()?.showMemberCreatedDate ?: false if (cached != null) { - _state.update { it.copy(member = cached, currentFronts = fronts, isLoading = false) } + _state.update { + it.copy( + member = cached, + currentFronts = fronts, + showCreatedDate = showCreated, + isLoading = false, + ) + } } else { _state.update { it.copy(isLoading = false) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index 09b4f47..edda94d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -842,6 +842,34 @@ fun SystemEditScreen( } } + SectionHeader("Display") + // Part of this form rather than an instant-apply toggle, so it + // saves with the Save Changes button like everything else here. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.updateForm { + copy(showMemberCreatedDate = !showMemberCreatedDate) + } + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Show member created dates", style = MaterialTheme.typography.bodyLarge) + Text( + "Show when each member was added, on their profile.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = form.showMemberCreatedDate, + onCheckedChange = { viewModel.updateForm { copy(showMemberCreatedDate = it) } }, + ) + } + Spacer(Modifier.height(8.dp)) Button( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt index 9ea35c1..84935c3 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt @@ -24,6 +24,7 @@ data class SystemEditForm( val avatarUrl: String = "", val color: String = "", val privacy: String = "private", + val showMemberCreatedDate: Boolean = false, ) data class SystemEditUiState( @@ -37,6 +38,7 @@ data class SystemEditUiState( @HiltViewModel class SystemEditViewModel @Inject constructor( private val api: SheafApiService, + private val cache: systems.lupine.sheaf.data.db.LocalCache, @ApplicationContext private val context: Context, val markdownImages: systems.lupine.sheaf.ui.components.MarkdownImageDelegate, ) : ViewModel() { @@ -65,6 +67,7 @@ class SystemEditViewModel @Inject constructor( avatarUrl = system.avatarUrl ?: "", color = system.color ?: "", privacy = system.privacy, + showMemberCreatedDate = system.showMemberCreatedDate, ) _state.update { it.copy(isLoading = false) } } @@ -91,9 +94,16 @@ class SystemEditViewModel @Inject constructor( avatarUrl = f.avatarUrl.takeIf { it.isNotBlank() }, color = f.color.takeIf { it.isNotBlank() }, privacy = f.privacy, + showMemberCreatedDate = f.showMemberCreatedDate, )) } - .onSuccess { _state.update { it.copy(isSaving = false, saved = true) } } + .onSuccess { updated -> + // Write through so display preferences read from the cached + // system (the member profile's created-date row) reflect the + // change straight away, without waiting for a Home refresh. + runCatching { cache.saveSystem(updated) } + _state.update { it.copy(isSaving = false, saved = true) } + } .onFailure { e -> _state.update { it.copy(isSaving = false, error = e.toUserMessage()) } } } } diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt index 39d447f..2408740 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt @@ -90,4 +90,46 @@ class ModelContractsTest { assertEquals(false, active.isArchived) assertEquals(true, active.copy(archivedAt = "2026-01-02T00:00:00Z").isArchived) } + + @Test fun `show_member_created_date round-trips under its wire name`() { + // A camelCase slip here wouldn't fail anything loudly: the toggle would + // just never stick, because the server ignores unknown keys on PATCH and + // reports its unchanged value back. + val adapter = moshi.adapter(SystemUpdate::class.java) + assertEquals( + """{"show_member_created_date":true}""", + adapter.toJson(SystemUpdate(showMemberCreatedDate = true)), + ) + val read = moshi.adapter(SystemRead::class.java).fromJson( + """ + {"id":"s1","name":"Sys","description":null,"tag":null,"avatar_url":null, + "color":null,"privacy":"private","delete_confirmation":null, + "show_member_created_date":true, + "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"} + """.trimIndent(), + ) + assertEquals(true, read?.showMemberCreatedDate) + } + + @Test fun `an omitted show_member_created_date reads as off`() { + // Older cached payloads and older servers won't carry the field; the + // display has to default to off rather than blow up or leak the date. + val read = moshi.adapter(SystemRead::class.java).fromJson( + """ + {"id":"s1","name":"Sys","description":null,"tag":null,"avatar_url":null, + "color":null,"privacy":"private","delete_confirmation":null, + "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"} + """.trimIndent(), + ) + assertEquals(false, read?.showMemberCreatedDate) + } + + @Test fun `a false toggle is still sent rather than omitted`() { + // Turning the setting back off has to reach the wire. Moshi drops nulls, + // so the form's Boolean must be non-null false, not null. + assertEquals( + """{"show_member_created_date":false}""", + moshi.adapter(SystemUpdate::class.java).toJson(SystemUpdate(showMemberCreatedDate = false)), + ) + } }