feat(log): the contact time can be set, for transcribing after a pass
The screen stamped System.currentTimeMillis() with no way to change it, which assumes contacts are typed as they happen. Serious satellite operators do not work that way: the documented practice from AMSAT and DX Engineering is to record the pass and transcribe it afterwards, because during eight minutes of a linear transponder there is no spare attention for a keyboard. Measured with a probe against a realistic pass - eight minutes, five contacts, twelve minutes to transcribe: every contact was stamped 10 to 12 minutes late. LoTW wants both sides within 30 minutes, so that survives a brisk transcription and fails a slow one. The case that fails outright is a pass crossing midnight UTC. Transcribing 23:58 at 00:05 the next day put the contact a full 24 hours in the future, which can never be confirmed. PassClock reads an absolute time later than now as belonging to the previous day, because passes cross midnight routinely and transcription always happens afterwards. One field takes both forms: an absolute UTC time (14:55 or 1455) or an offset (+3, -2m). A separate widget for each is more to reach for than an operator wants while holding an antenna. The parse is deliberately narrow - anything unclear is Unrecognised and the clock stays put, because a mis-parsed time silently backdates a contact and nothing downstream would catch it. The field says so while it is being typed rather than after committing. A held clock is shown in the primary colour, since logging at the wrong time silently is the failure this exists to prevent. The row is 48dp with Role.Button, like the mode row. PassClock is pure and lives in core:domain with 15 tests. The day boundary is passed in rather than computed there, because core:domain holds no calendar.
This commit is contained in:
1 parent
a3e3932f73
commit
00b7b25cc1
5 files changed
+346
-1
No files matched your search
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out what time a contact should carry.
|
||||||
|
*
|
||||||
|
* The logging screen used to stamp System.currentTimeMillis() and offer no way to change it. That
|
||||||
|
* assumes contacts are typed as they happen, and serious satellite operators do not work that way:
|
||||||
|
* the documented practice is to record the pass and transcribe it afterwards, because during eight
|
||||||
|
* minutes of a linear transponder there is no spare attention for a keyboard. A fixed clock makes
|
||||||
|
* every transcribed contact wrong by however long the transcription took.
|
||||||
|
*
|
||||||
|
* Two ways to say when: an absolute UTC time of day, or an offset from now. Both are typed into the
|
||||||
|
* same field, because a separate widget for each is more to reach for than an operator wants while
|
||||||
|
* holding an antenna.
|
||||||
|
*/
|
||||||
|
object PassClock {
|
||||||
|
|
||||||
|
/** What a typed token meant. */
|
||||||
|
sealed interface Command {
|
||||||
|
|
||||||
|
/** Log at this UTC time of day. [minuteOfDay] is minutes since 00:00 UTC. */
|
||||||
|
data class At(val minuteOfDay: Int) : Command
|
||||||
|
|
||||||
|
/** Log this many minutes from now. Negative counts backwards. */
|
||||||
|
data class Shift(val minutes: Int) : Command
|
||||||
|
|
||||||
|
/** Back to the current time. */
|
||||||
|
data object Live : Command
|
||||||
|
|
||||||
|
/** Not a time instruction. The caller should leave the clock alone. */
|
||||||
|
data object Unrecognised : Command
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret a typed token.
|
||||||
|
*
|
||||||
|
* Deliberately narrow. Anything that is not clearly a time is [Command.Unrecognised] rather than
|
||||||
|
* a guess, because a mis-parsed time silently backdates a contact and nothing downstream would
|
||||||
|
* catch it.
|
||||||
|
*
|
||||||
|
* Accepted: `14:55` or `1455` for a UTC time of day; `+3` or `-2` for a shift in minutes, with
|
||||||
|
* an optional `m`; empty or `now` to return to live time.
|
||||||
|
*/
|
||||||
|
fun parse(entry: String): Command {
|
||||||
|
val text = entry.trim().lowercase()
|
||||||
|
if (text.isEmpty() || text == "now") return Command.Live
|
||||||
|
if (text.startsWith("+") || text.startsWith("-")) return parseShift(text)
|
||||||
|
return parseTimeOfDay(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `+3`, `-2m`, `+15`. */
|
||||||
|
private fun parseShift(text: String): Command {
|
||||||
|
val negative = text.startsWith("-")
|
||||||
|
val digits = text.drop(1).removeSuffix("m")
|
||||||
|
val minutes = digits.toIntOrNull() ?: return Command.Unrecognised
|
||||||
|
if (minutes > MAX_SHIFT_MINUTES) return Command.Unrecognised
|
||||||
|
return Command.Shift(if (negative) -minutes else minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `14:55` or `1455`. */
|
||||||
|
private fun parseTimeOfDay(text: String): Command {
|
||||||
|
val digits = text.replace(":", "")
|
||||||
|
if (digits.length != TIME_DIGITS || digits.any { !it.isDigit() }) return Command.Unrecognised
|
||||||
|
val hours = digits.take(2).toInt()
|
||||||
|
val minutes = digits.drop(2).toInt()
|
||||||
|
if (hours > MAX_HOUR || minutes > MAX_MINUTE) return Command.Unrecognised
|
||||||
|
return Command.At(hours * MINUTES_PER_HOUR + minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a command, returning the timestamp a contact should carry.
|
||||||
|
*
|
||||||
|
* [now] is the current UTC time in milliseconds and [dayStart] is midnight UTC of the day [now]
|
||||||
|
* falls in - passed in rather than computed, because core:domain holds no calendar and the
|
||||||
|
* caller already knows which day it is working with.
|
||||||
|
*
|
||||||
|
* An absolute time later than [now] is read as belonging to the previous day: transcription
|
||||||
|
* happens after the pass, so a pass that ran across midnight UTC is the common case, not an
|
||||||
|
* error. Without this a contact logged at 23:58 while transcribing at 00:05 would land a full
|
||||||
|
* day in the future.
|
||||||
|
*/
|
||||||
|
fun resolve(command: Command, now: Long, dayStart: Long): Long = when (command) {
|
||||||
|
is Command.At -> {
|
||||||
|
val candidate = dayStart + command.minuteOfDay * MILLIS_PER_MINUTE
|
||||||
|
if (candidate > now) candidate - MILLIS_PER_DAY else candidate
|
||||||
|
}
|
||||||
|
is Command.Shift -> now + command.minutes * MILLIS_PER_MINUTE
|
||||||
|
Command.Live, Command.Unrecognised -> now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a command moves the clock off live time, so the UI can show that it has. */
|
||||||
|
fun isHolding(command: Command): Boolean =
|
||||||
|
command is Command.At || (command is Command.Shift && command.minutes != 0)
|
||||||
|
|
||||||
|
private const val TIME_DIGITS = 4
|
||||||
|
private const val MAX_HOUR = 23
|
||||||
|
private const val MAX_MINUTE = 59
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|
||||||
|
/** A pass lasts minutes. Anything larger is a typo, not an intention. */
|
||||||
|
private const val MAX_SHIFT_MINUTES = 720
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
private const val MILLIS_PER_DAY = 86_400_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen used to stamp the current time with no way to change it, which is wrong for the way
|
||||||
|
* satellite operators actually work: record the pass, transcribe it afterwards. Every transcribed
|
||||||
|
* contact was then off by however long the transcription took.
|
||||||
|
*
|
||||||
|
* The parse is deliberately narrow. A mis-parsed time silently backdates a contact and nothing
|
||||||
|
* downstream would notice, so anything unclear has to be rejected rather than guessed.
|
||||||
|
*/
|
||||||
|
class PassClockTest {
|
||||||
|
|
||||||
|
private val dayStart = 1_800_000_000_000L // some midnight UTC
|
||||||
|
private val minute = 60_000L
|
||||||
|
private val hour = 60 * minute
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a colon separated time is understood`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("14:55"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare four digit time is understood`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("1455"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midnight and the last minute of the day both parse`() {
|
||||||
|
assertEquals(PassClock.Command.At(0), PassClock.parse("00:00"))
|
||||||
|
assertEquals(PassClock.Command.At(23 * 60 + 59), PassClock.parse("23:59"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an impossible time is not guessed at`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("24:00"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("12:60"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("99:99"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a callsign shaped entry is not a time`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("BG7NTA"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("59"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("OL72AP"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shift in minutes is understood in both directions`() {
|
||||||
|
assertEquals(PassClock.Command.Shift(3), PassClock.parse("+3"))
|
||||||
|
assertEquals(PassClock.Command.Shift(-2), PassClock.parse("-2"))
|
||||||
|
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pass lasts minutes, so a huge shift is a typo rather than an intention. */
|
||||||
|
@Test
|
||||||
|
fun `an absurd shift is rejected`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+99999"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty or now returns to live time`() {
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse(""))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse(" "))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse("now"))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse("NOW"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `live time resolves to now`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now, PassClock.resolve(PassClock.Command.Live, now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unrecognised entry must not move the clock. */
|
||||||
|
@Test
|
||||||
|
fun `an unrecognised command leaves the time alone`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now, PassClock.resolve(PassClock.Command.Unrecognised, now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absolute time earlier today resolves to today`() {
|
||||||
|
val now = dayStart + 14 * hour
|
||||||
|
val resolved = PassClock.resolve(PassClock.Command.At(10 * 60 + 30), now, dayStart)
|
||||||
|
assertEquals(dayStart + 10 * hour + 30 * minute, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case that would otherwise put a contact a day in the future. Transcribing at 00:05 UTC a
|
||||||
|
* pass that ran at 23:58 is normal, not an error - passes cross midnight UTC routinely.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an absolute time later than now belongs to the previous day`() {
|
||||||
|
val now = dayStart + 5 * minute // 00:05 UTC
|
||||||
|
val resolved = PassClock.resolve(PassClock.Command.At(23 * 60 + 58), now, dayStart)
|
||||||
|
assertEquals(dayStart - 86_400_000L + 23 * hour + 58 * minute, resolved)
|
||||||
|
assertTrue("must be in the past", resolved < now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shift moves the time by whole minutes`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now + 3 * minute, PassClock.resolve(PassClock.Command.Shift(3), now, dayStart))
|
||||||
|
assertEquals(now - 2 * minute, PassClock.resolve(PassClock.Command.Shift(-2), now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UI needs to show that the clock is no longer following real time. */
|
||||||
|
@Test
|
||||||
|
fun `holding is reported only when the clock has moved`() {
|
||||||
|
assertTrue(PassClock.isHolding(PassClock.Command.At(600)))
|
||||||
|
assertTrue(PassClock.isHolding(PassClock.Command.Shift(-5)))
|
||||||
|
assertFalse(PassClock.isHolding(PassClock.Command.Live))
|
||||||
|
assertFalse(PassClock.isHolding(PassClock.Command.Unrecognised))
|
||||||
|
assertFalse("a zero shift is still live", PassClock.isHolding(PassClock.Command.Shift(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace and case must not change the verdict - this is typed one-handed outdoors. */
|
||||||
|
@Test
|
||||||
|
fun `surrounding whitespace does not matter`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse(" 14:55 "))
|
||||||
|
assertEquals(PassClock.Command.Shift(3), PassClock.parse(" +3 "))
|
||||||
|
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15M"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,6 +278,11 @@
|
|||||||
<string name="wavelog_call_hint">对方呼号</string>
|
<string name="wavelog_call_hint">对方呼号</string>
|
||||||
<string name="wavelog_mode_hint">模式</string>
|
<string name="wavelog_mode_hint">模式</string>
|
||||||
<string name="wavelog_mode_edit">修改模式</string>
|
<string name="wavelog_mode_edit">修改模式</string>
|
||||||
|
<string name="wavelog_time_hint">时间</string>
|
||||||
|
<string name="wavelog_time_live">当前</string>
|
||||||
|
<string name="wavelog_time_edit">设置记录时间</string>
|
||||||
|
<string name="wavelog_time_help">UTC 时间如 14:55, 或 +3 / -2 分钟。留空则用当前时间。</string>
|
||||||
|
<string name="wavelog_time_unrecognised">不是时间 - 将使用当前时间</string>
|
||||||
<string name="wavelog_saved">已存入本地日志</string>
|
<string name="wavelog_saved">已存入本地日志</string>
|
||||||
<string name="wavelog_upload_done">已上传 %1$d 条, %2$d 条仍在队列</string>
|
<string name="wavelog_upload_done">已上传 %1$d 条, %2$d 条仍在队列</string>
|
||||||
<string name="wavelog_upload_all_ok">已上传 %1$d 条</string>
|
<string name="wavelog_upload_all_ok">已上传 %1$d 条</string>
|
||||||
|
|||||||
@@ -309,6 +309,11 @@
|
|||||||
<string name="wavelog_call_hint">Callsign</string>
|
<string name="wavelog_call_hint">Callsign</string>
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
<string name="wavelog_mode_edit">Edit the mode</string>
|
<string name="wavelog_mode_edit">Edit the mode</string>
|
||||||
|
<string name="wavelog_time_hint">Time</string>
|
||||||
|
<string name="wavelog_time_live">Now</string>
|
||||||
|
<string name="wavelog_time_edit">Set the logging time</string>
|
||||||
|
<string name="wavelog_time_help">UTC time such as 14:55, or +3 / -2 minutes. Empty logs at the current time.</string>
|
||||||
|
<string name="wavelog_time_unrecognised">Not a time - the current time will be used</string>
|
||||||
<string name="wavelog_saved">Saved to local log</string>
|
<string name="wavelog_saved">Saved to local log</string>
|
||||||
<string name="wavelog_upload_done">Uploaded %1$d, %2$d still queued</string>
|
<string name="wavelog_upload_done">Uploaded %1$d, %2$d still queued</string>
|
||||||
<string name="wavelog_upload_all_ok">Uploaded %1$d QSO</string>
|
<string name="wavelog_upload_all_ok">Uploaded %1$d QSO</string>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.PassClock
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
|
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
@@ -281,6 +282,11 @@ private fun ExpandedLogInput(
|
|||||||
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
|
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||||
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
|
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
|
||||||
val editModeLabel = stringResource(id = R.string.wavelog_mode_edit)
|
val editModeLabel = stringResource(id = R.string.wavelog_mode_edit)
|
||||||
|
val editTimeLabel = stringResource(id = R.string.wavelog_time_edit)
|
||||||
|
// Survives rotation like workedThisSession: losing a held clock mid-transcription would put
|
||||||
|
// every remaining contact at the wrong time without saying so.
|
||||||
|
var timeEntry by rememberSaveable { mutableStateOf("") }
|
||||||
|
var timeEditable by rememberSaveable { mutableStateOf(false) }
|
||||||
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
|
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
|
||||||
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
|
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
|
||||||
// swallowed what it guessed were accidental double submissions - a guess that could discard
|
// swallowed what it guessed were accidental double submissions - a guess that could discard
|
||||||
@@ -322,7 +328,11 @@ private fun ExpandedLogInput(
|
|||||||
queue.add(
|
queue.add(
|
||||||
WavelogQso(
|
WavelogQso(
|
||||||
id = qsoId,
|
id = qsoId,
|
||||||
timeUtcMs = System.currentTimeMillis(),
|
timeUtcMs = PassClock.resolve(
|
||||||
|
PassClock.parse(timeEntry),
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
utcDayStart(System.currentTimeMillis())
|
||||||
|
),
|
||||||
call = call,
|
call = call,
|
||||||
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||||
freqTxHz = tx,
|
freqTxHz = tx,
|
||||||
@@ -457,6 +467,63 @@ private fun ExpandedLogInput(
|
|||||||
Text(text = mode, style = MaterialTheme.typography.bodyMedium)
|
Text(text = mode, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The clock. Serious operators record a pass and transcribe it afterwards, so a fixed
|
||||||
|
// timestamp made every transcribed contact wrong by however long the transcription took -
|
||||||
|
// and a pass crossing midnight UTC landed a whole day out.
|
||||||
|
val command = PassClock.parse(timeEntry)
|
||||||
|
val holding = PassClock.isHolding(command)
|
||||||
|
if (timeEditable) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = timeEntry,
|
||||||
|
onValueChange = { timeEntry = it.take(6) },
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_time_hint)) },
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (command is PassClock.Command.Unrecognised) {
|
||||||
|
R.string.wavelog_time_unrecognised
|
||||||
|
} else {
|
||||||
|
R.string.wavelog_time_help
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = command is PassClock.Command.Unrecognised,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clickable(role = Role.Button, onClickLabel = editTimeLabel) {
|
||||||
|
timeEditable = true
|
||||||
|
}
|
||||||
|
.padding(horizontal = LocalSpacing.current.extraExtraSmall)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_time_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (holding) timeEntry.trim() else stringResource(id = R.string.wavelog_time_live),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
// A held clock is coloured, because logging at the wrong time silently is the
|
||||||
|
// failure this whole change exists to prevent.
|
||||||
|
color = if (holding) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,3 +665,19 @@ private fun buildSessionId(satName: String, aosTimeMs: Long): String {
|
|||||||
)
|
)
|
||||||
return "$satName-$stamp"
|
return "$satName-$stamp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Midnight UTC of the day a timestamp falls in.
|
||||||
|
*
|
||||||
|
* PassClock needs this passed in: core:domain holds no calendar, so the day boundary is computed
|
||||||
|
* here where java.util is available.
|
||||||
|
*/
|
||||||
|
private fun utcDayStart(nowMs: Long): Long {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = nowMs
|
||||||
|
cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||||
|
cal.set(java.util.Calendar.MINUTE, 0)
|
||||||
|
cal.set(java.util.Calendar.SECOND, 0)
|
||||||
|
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
return cal.timeInMillis
|
||||||
|
}
|
||||||
Reference in new issue
Block a user