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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?> = 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
Expand Down Expand Up @@ -732,29 +735,53 @@ 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()) }
}
}
} 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) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ data class SystemEditForm(
val avatarUrl: String = "",
val color: String = "",
val privacy: String = "private",
val showMemberCreatedDate: Boolean = false,
)

data class SystemEditUiState(
Expand All @@ -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() {
Expand Down Expand Up @@ -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) }
}
Expand All @@ -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()) } }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
}
}
Loading