Skip to content

Commit 650da23

Browse files
TehPeGaSuStrevarj
authored andcommitted
Add a custom SimpleDateFormat timestamp pattern option
Adds TimeFormat.CUSTOM alongside AUTO/H12/H24, backed by a user-typed SimpleDateFormat pattern (e.g. dd/MM/yyyy - HH:mm:ss) persisted via DataStore. Wired through the same shared time formatter both bubble mode and the COMPACT/TWO_LINE density renderers already use, so it applies everywhere without per-density plumbing. A malformed pattern falls back to AUTO instead of crashing the row.
1 parent a5f0ab9 commit 650da23

13 files changed

Lines changed: 122 additions & 15 deletions

File tree

app/src/main/kotlin/io/github/trevarj/motd/MainActivity.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ class MainActivity :
166166
uiFontScalePercent = appearance.uiFontScalePercent,
167167
fontChoice = appearance.fontChoice,
168168
customFontFile = customFontFile,
169-
timestampConfig = TimestampConfig(appearance.showTimestamps, appearance.timeFormat),
169+
timestampConfig =
170+
TimestampConfig(
171+
appearance.showTimestamps,
172+
appearance.timeFormat,
173+
appearance.customTimeFormatPattern,
174+
),
170175
messageSpacing = appearance.messageSpacing,
171176
bubbleCornerStyle = appearance.bubbleCornerStyle,
172177
) {

app/src/main/kotlin/io/github/trevarj/motd/data/backup/ConfigurationBackup.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ class ConfigurationBackupRepositoryImpl
440440
appearancePrefs.setFontChoice(it.fontChoice)
441441
appearancePrefs.setShowTimestamps(it.showTimestamps)
442442
appearancePrefs.setTimeFormat(it.timeFormat)
443+
appearancePrefs.setCustomTimeFormatPattern(it.customTimeFormatPattern)
443444
appearancePrefs.setMessageSpacing(it.messageSpacing)
444445
appearancePrefs.setBubbleCornerStyle(it.bubbleCornerStyle)
445446
appearancePrefs.setLauncherIcon(it.launcherIcon)

app/src/main/kotlin/io/github/trevarj/motd/data/prefs/AppearancePrefs.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ enum class ChatWallpaperPreset { NONE, CHATTER, CHANNELS, TERMINAL, RELAY, SIGNA
128128

129129
enum class FontChoice { SYSTEM, SANS, SERIF, MONOSPACE, JETBRAINS_MONO, CUSTOM }
130130

131-
enum class TimeFormat { AUTO, H12, H24 }
131+
enum class TimeFormat { AUTO, H12, H24, CUSTOM }
132+
133+
/** Default custom-timestamp pattern, [java.text.SimpleDateFormat] syntax (not strftime). */
134+
const val DEFAULT_CUSTOM_TIME_FORMAT = "dd/MM/yyyy - HH:mm:ss"
132135

133136
enum class MessageSpacing { COMPACT, DEFAULT, RELAXED }
134137

@@ -155,6 +158,8 @@ data class AppearanceConfig(
155158
val fontChoice: FontChoice = FontChoice.SYSTEM,
156159
val showTimestamps: Boolean = true,
157160
val timeFormat: TimeFormat = TimeFormat.AUTO,
161+
// SimpleDateFormat pattern, only consulted when timeFormat == CUSTOM.
162+
val customTimeFormatPattern: String = DEFAULT_CUSTOM_TIME_FORMAT,
158163
val messageSpacing: MessageSpacing = MessageSpacing.DEFAULT,
159164
val bubbleCornerStyle: BubbleCornerStyle = BubbleCornerStyle.ROUNDED,
160165
val launcherIcon: LauncherIcon = LauncherIcon.DEFAULT,
@@ -184,6 +189,8 @@ interface AppearancePrefs {
184189

185190
suspend fun setTimeFormat(format: TimeFormat)
186191

192+
suspend fun setCustomTimeFormatPattern(pattern: String)
193+
187194
suspend fun setMessageSpacing(spacing: MessageSpacing)
188195

189196
suspend fun setBubbleCornerStyle(style: BubbleCornerStyle)

app/src/main/kotlin/io/github/trevarj/motd/data/prefs/AppearancePrefsImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ private val CONVERSATION_FONT_SCALE = intPreferencesKey("conversation_font_scale
2323
private val FONT_CHOICE = stringPreferencesKey("font_choice_v1")
2424
private val SHOW_TIMESTAMPS = booleanPreferencesKey("show_timestamps_v1")
2525
private val TIME_FORMAT = stringPreferencesKey("time_format_v1")
26+
private val CUSTOM_TIME_FORMAT_PATTERN = stringPreferencesKey("custom_time_format_pattern_v1")
2627
private val MESSAGE_SPACING = stringPreferencesKey("message_spacing_v1")
2728
private val BUBBLE_CORNER_STYLE = stringPreferencesKey("bubble_corner_style_v1")
2829
private val LAUNCHER_ICON = stringPreferencesKey("launcher_icon_v1")
@@ -84,6 +85,9 @@ class AppearancePrefsImpl
8485
timeFormat =
8586
prefs[TIME_FORMAT]?.let { runCatching { TimeFormat.valueOf(it) }.getOrNull() }
8687
?: TimeFormat.AUTO,
88+
customTimeFormatPattern =
89+
prefs[CUSTOM_TIME_FORMAT_PATTERN]?.takeIf { it.isNotBlank() }
90+
?: DEFAULT_CUSTOM_TIME_FORMAT,
8791
messageSpacing =
8892
prefs[MESSAGE_SPACING]?.let { runCatching { MessageSpacing.valueOf(it) }.getOrNull() }
8993
?: MessageSpacing.DEFAULT,
@@ -145,6 +149,10 @@ class AppearancePrefsImpl
145149
store.edit { it[TIME_FORMAT] = format.name }
146150
}
147151

152+
override suspend fun setCustomTimeFormatPattern(pattern: String) {
153+
store.edit { it[CUSTOM_TIME_FORMAT_PATTERN] = pattern.ifBlank { DEFAULT_CUSTOM_TIME_FORMAT } }
154+
}
155+
148156
override suspend fun setMessageSpacing(spacing: MessageSpacing) {
149157
store.edit { it[MESSAGE_SPACING] = spacing.name }
150158
}

app/src/main/kotlin/io/github/trevarj/motd/ui/chatlist/ChatListRowItem.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import io.github.trevarj.motd.audio.formatAudioDuration
5858
import io.github.trevarj.motd.audio.parseAudioAttachments
5959
import io.github.trevarj.motd.data.db.BufferType
6060
import io.github.trevarj.motd.data.db.ChatListRow
61+
import io.github.trevarj.motd.data.prefs.TimeFormat
6162
import io.github.trevarj.motd.service.PresenceState
6263
import io.github.trevarj.motd.ui.components.AdvertisedActivityDot
6364
import io.github.trevarj.motd.ui.components.Avatar
@@ -67,6 +68,7 @@ import io.github.trevarj.motd.ui.components.MutedActivityBadge
6768
import io.github.trevarj.motd.ui.components.NetworkChip
6869
import io.github.trevarj.motd.ui.components.UnreadBadge
6970
import io.github.trevarj.motd.ui.components.avatarsHidden
71+
import io.github.trevarj.motd.ui.components.rememberMessageTimeFormatter
7072
import io.github.trevarj.motd.ui.components.resolveIs24Hour
7173
import io.github.trevarj.motd.ui.theme.LocalMotdSemanticColors
7274
import io.github.trevarj.motd.ui.theme.LocalNickColors
@@ -201,13 +203,14 @@ fun ChatListRowItem(
201203
// Resolved per-nick color (also used to tint the friend star), matching sender coloring.
202204
val nickColor = LocalNickColors.current.nick(row.displayName, MaterialTheme.colorScheme.onSurfaceVariant)
203205
val spacing = LocalSpacing.current
204-
// The chat-list time always shows regardless of the in-chat "show timestamps" toggle; only its
205-
// 12h/24h format follows the user's preference (AUTO falls back to the device setting).
206+
// Chat-list time always shows regardless of the in-chat "show timestamps" toggle.
206207
val context = LocalContext.current
208+
val timestampConfig = LocalTimestampConfig.current
207209
// Reads a system setting via a Binder call; memoize per row rather than re-querying on every
208210
// recomposition (this composable is invoked once per visible row).
209211
val is24HourDevice = remember(context) { DateFormat.is24HourFormat(context) }
210-
val is24Hour = resolveIs24Hour(LocalTimestampConfig.current.format, is24HourDevice)
212+
val is24Hour = resolveIs24Hour(timestampConfig.format, is24HourDevice)
213+
val formatTimestamp = rememberMessageTimeFormatter()
211214
val queryPresence = presence.takeIf { row.type == BufferType.QUERY }
212215
val badges = chatListBadgeState(row)
213216
val isUnread = !row.muted && row.unreadCount > 0
@@ -441,7 +444,12 @@ fun ChatListRowItem(
441444
) {
442445
row.lastMessageTime?.let { time ->
443446
Text(
444-
text = relativeChatTime(time, is24Hour = is24Hour),
447+
text =
448+
if (timestampConfig.format == TimeFormat.CUSTOM) {
449+
formatTimestamp(time)
450+
} else {
451+
relativeChatTime(time, is24Hour = is24Hour)
452+
},
445453
style = MaterialTheme.typography.labelSmall,
446454
color = MaterialTheme.colorScheme.onSurfaceVariant,
447455
)

app/src/main/kotlin/io/github/trevarj/motd/ui/components/MessageBubble.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,10 +1855,12 @@ internal fun formatTime(ms: Long): String = rememberMessageTimeFormatter()(ms)
18551855
internal fun rememberMessageTimeFormatter(): (Long) -> String {
18561856
val context = LocalContext.current
18571857
val locale = LocalLocale.current.platformLocale
1858-
val timeFormat = LocalTimestampConfig.current.format
1858+
val timestampConfig = LocalTimestampConfig.current
1859+
val timeFormat = timestampConfig.format
1860+
val customPattern = timestampConfig.customPattern
18591861
val is24 = remember(context, locale) { DateFormat.is24HourFormat(context) }
18601862
val formatter =
1861-
remember(is24, locale, timeFormat) {
1863+
remember(is24, locale, timeFormat, customPattern) {
18621864
when (timeFormat) {
18631865
// getTimeFormat honors the 12/24h system setting; not thread-safe but only used on the
18641866
// UI thread.
@@ -1877,6 +1879,12 @@ internal fun rememberMessageTimeFormatter(): (Long) -> String {
18771879
locale,
18781880
)
18791881
}
1882+
1883+
// A malformed user-typed pattern falls back to AUTO rather than crashing the row.
1884+
TimeFormat.CUSTOM -> {
1885+
runCatching { java.text.SimpleDateFormat(customPattern, locale) }
1886+
.getOrElse { DateFormat.getTimeFormat(context) ?: JavaDateFormat.getTimeInstance(JavaDateFormat.SHORT) }
1887+
}
18801888
}
18811889
}
18821890
return remember(formatter) {
@@ -1894,8 +1902,13 @@ internal fun resolveIs24Hour(
18941902
): Boolean =
18951903
when (format) {
18961904
TimeFormat.AUTO -> deviceIs24
1905+
18971906
TimeFormat.H12 -> false
1907+
18981908
TimeFormat.H24 -> true
1909+
1910+
// CUSTOM's pattern decides its own hour cycle; this hint isn't consulted for it.
1911+
TimeFormat.CUSTOM -> deviceIs24
18991912
}
19001913

19011914
@Preview

app/src/main/kotlin/io/github/trevarj/motd/ui/search/SearchScreen.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ import io.github.trevarj.motd.R
6161
import io.github.trevarj.motd.data.db.MessageEntity
6262
import io.github.trevarj.motd.data.db.MessageKind
6363
import io.github.trevarj.motd.data.db.SearchHit
64+
import io.github.trevarj.motd.data.prefs.TimeFormat
6465
import io.github.trevarj.motd.data.repo.SearchCoverage
6566
import io.github.trevarj.motd.ui.chatlist.relativeChatTime
6667
import io.github.trevarj.motd.ui.components.EmptyState
68+
import io.github.trevarj.motd.ui.components.rememberMessageTimeFormatter
6769
import io.github.trevarj.motd.ui.components.resolveIs24Hour
6870
import io.github.trevarj.motd.ui.theme.LocalTimestampConfig
6971
import io.github.trevarj.motd.ui.theme.MotdMotion
@@ -427,13 +429,14 @@ private fun SearchRow(
427429
tag: String,
428430
onClick: () -> Unit,
429431
) {
430-
// Search results always show a time (independent of the in-chat "show timestamps" toggle);
431-
// only its 12h/24h format follows the user's preference.
432+
// Search results always show a time, independent of the in-chat "show timestamps" toggle.
432433
val context = LocalContext.current
434+
val timestampConfig = LocalTimestampConfig.current
433435
// Reads a system setting via a Binder call; memoize per row rather than re-querying on every
434436
// recomposition (this composable is invoked once per visible result row).
435437
val is24HourDevice = remember(context) { DateFormat.is24HourFormat(context) }
436-
val is24Hour = resolveIs24Hour(LocalTimestampConfig.current.format, is24HourDevice)
438+
val is24Hour = resolveIs24Hour(timestampConfig.format, is24HourDevice)
439+
val formatTimestamp = rememberMessageTimeFormatter()
437440
Row(
438441
modifier =
439442
Modifier
@@ -458,7 +461,12 @@ private fun SearchRow(
458461
// A server hit without a time tag carries 0; render nothing rather than the epoch.
459462
if (serverTime > 0) {
460463
Text(
461-
text = relativeChatTime(serverTime, is24Hour = is24Hour),
464+
text =
465+
if (timestampConfig.format == TimeFormat.CUSTOM) {
466+
formatTimestamp(serverTime)
467+
} else {
468+
relativeChatTime(serverTime, is24Hour = is24Hour)
469+
},
462470
style = MaterialTheme.typography.labelSmall,
463471
color = MaterialTheme.colorScheme.onSurfaceVariant,
464472
modifier = Modifier.align(Alignment.Top),

app/src/main/kotlin/io/github/trevarj/motd/ui/settings/AppearanceSettingsScreen.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ fun AppearanceSettingsScreen(
140140
onImportCustomFont = viewModel::importCustomFont,
141141
onShowTimestamps = viewModel::setShowTimestamps,
142142
onTimeFormat = viewModel::setTimeFormat,
143+
onCustomTimeFormatPattern = viewModel::setCustomTimeFormatPattern,
143144
onMessageSpacing = viewModel::setMessageSpacing,
144145
onBubbleCornerStyle = viewModel::setBubbleCornerStyle,
145146
onLauncherIcon = viewModel::setLauncherIcon,
@@ -166,6 +167,7 @@ fun AppearanceSettingsContent(
166167
onFontChoice: (FontChoice) -> Unit,
167168
onShowTimestamps: (Boolean) -> Unit,
168169
onTimeFormat: (TimeFormat) -> Unit,
170+
onCustomTimeFormatPattern: (String) -> Unit,
169171
onMessageSpacing: (io.github.trevarj.motd.data.prefs.MessageSpacing) -> Unit,
170172
onBubbleCornerStyle: (io.github.trevarj.motd.data.prefs.BubbleCornerStyle) -> Unit,
171173
onLauncherIcon: (LauncherIcon) -> Unit,
@@ -300,7 +302,12 @@ fun AppearanceSettingsContent(
300302
)
301303
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
302304
SubLabel(stringResource(R.string.settings_time_format))
303-
TimeFormatGroup(current = appearance.timeFormat, onSelect = onTimeFormat)
305+
TimeFormatGroup(
306+
current = appearance.timeFormat,
307+
onSelect = onTimeFormat,
308+
customPattern = appearance.customTimeFormatPattern,
309+
onCustomPatternChange = onCustomTimeFormatPattern,
310+
)
304311
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
305312
SubLabel(stringResource(R.string.settings_message_spacing))
306313
MessageSpacingGroup(current = appearance.messageSpacing, onSelect = onMessageSpacing)
@@ -799,13 +806,16 @@ private fun DensityGroup(
799806
private fun TimeFormatGroup(
800807
current: TimeFormat,
801808
onSelect: (TimeFormat) -> Unit,
809+
customPattern: String,
810+
onCustomPatternChange: (String) -> Unit,
802811
) {
803812
// Always enabled: the chat list keeps using the format even while message timestamps are hidden.
804813
val options =
805814
listOf(
806815
TimeFormat.AUTO to R.string.settings_time_format_auto,
807816
TimeFormat.H12 to R.string.settings_time_format_h12,
808817
TimeFormat.H24 to R.string.settings_time_format_h24,
818+
TimeFormat.CUSTOM to R.string.settings_time_format_custom,
809819
)
810820
Column(Modifier.selectableGroup()) {
811821
options.forEach { (format, labelRes) ->
@@ -817,6 +827,32 @@ private fun TimeFormatGroup(
817827
modifier = Modifier.testTag("settings_time_format_${format.name.lowercase()}"),
818828
)
819829
}
830+
AnimatedVisibility(
831+
visible = current == TimeFormat.CUSTOM,
832+
enter = fadeIn(MotdMotion.microFadeIn) + expandVertically(animationSpec = MotdMotion.contentSize),
833+
exit = fadeOut(MotdMotion.microFadeOut) + shrinkVertically(animationSpec = MotdMotion.contentSize),
834+
) {
835+
var draft by remember(customPattern) { mutableStateOf(customPattern) }
836+
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) {
837+
OutlinedTextField(
838+
value = draft,
839+
onValueChange = {
840+
draft = it
841+
onCustomPatternChange(it)
842+
},
843+
singleLine = true,
844+
label = { Text(stringResource(R.string.settings_time_format)) },
845+
placeholder = { Text(stringResource(R.string.settings_time_format_custom_hint)) },
846+
modifier = Modifier.fillMaxWidth().testTag("settings_time_format_custom_pattern"),
847+
)
848+
Text(
849+
text = stringResource(R.string.settings_time_format_custom_help),
850+
style = MaterialTheme.typography.bodySmall,
851+
color = MaterialTheme.colorScheme.onSurfaceVariant,
852+
modifier = Modifier.padding(top = 4.dp),
853+
)
854+
}
855+
}
820856
}
821857
}
822858

@@ -993,6 +1029,7 @@ private fun AppearanceSettingsPreview() {
9931029
onFontChoice = {},
9941030
onShowTimestamps = {},
9951031
onTimeFormat = {},
1032+
onCustomTimeFormatPattern = {},
9961033
onMessageSpacing = {},
9971034
onBubbleCornerStyle = {},
9981035
onLauncherIcon = {},
@@ -1025,6 +1062,7 @@ private fun AppearanceSettingsMinTextPreview() {
10251062
onFontChoice = {},
10261063
onShowTimestamps = {},
10271064
onTimeFormat = {},
1065+
onCustomTimeFormatPattern = {},
10281066
onMessageSpacing = {},
10291067
onBubbleCornerStyle = {},
10301068
onLauncherIcon = {},
@@ -1057,6 +1095,7 @@ private fun AppearanceSettingsMaxTextPreview() {
10571095
onFontChoice = {},
10581096
onShowTimestamps = {},
10591097
onTimeFormat = {},
1098+
onCustomTimeFormatPattern = {},
10601099
onMessageSpacing = {},
10611100
onBubbleCornerStyle = {},
10621101
onLauncherIcon = {},

app/src/main/kotlin/io/github/trevarj/motd/ui/settings/SettingsViewModel.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,11 @@ class SettingsViewModel
271271
appearancePrefs.setTimeFormat(format)
272272
}
273273

274+
fun setCustomTimeFormatPattern(pattern: String) =
275+
viewModelScope.launch {
276+
appearancePrefs.setCustomTimeFormatPattern(pattern)
277+
}
278+
274279
fun setMessageSpacing(spacing: io.github.trevarj.motd.data.prefs.MessageSpacing) =
275280
viewModelScope.launch {
276281
appearancePrefs.setMessageSpacing(spacing)

app/src/main/kotlin/io/github/trevarj/motd/ui/theme/MotdTheme.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.core.view.WindowCompat
2828
import io.github.trevarj.motd.data.prefs.AvatarStyle
2929
import io.github.trevarj.motd.data.prefs.BubbleCornerStyle
3030
import io.github.trevarj.motd.data.prefs.ColorThemePreset
31+
import io.github.trevarj.motd.data.prefs.DEFAULT_CUSTOM_TIME_FORMAT
3132
import io.github.trevarj.motd.data.prefs.DEFAULT_FONT_SCALE_PERCENT
3233
import io.github.trevarj.motd.data.prefs.FontChoice
3334
import io.github.trevarj.motd.data.prefs.LayoutDensity
@@ -120,6 +121,8 @@ val LocalAppFontFamily: ProvidableCompositionLocal<FontFamily?> = staticComposit
120121
data class TimestampConfig(
121122
val show: Boolean = true,
122123
val format: TimeFormat = TimeFormat.AUTO,
124+
// SimpleDateFormat pattern, only consulted when format == CUSTOM.
125+
val customPattern: String = DEFAULT_CUSTOM_TIME_FORMAT,
123126
)
124127

125128
/** CompositionLocal carrying the active timestamp display config; defaults to always-shown/AUTO. */

0 commit comments

Comments
 (0)