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)), + ) + } }