diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index de568f82..896dcb03 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -3245,7 +3245,7 @@ export default { const now = new Date(); const currentTime = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const currentHHMM = currentTime.slice(0, 5); - if (!this.trainingStart || !this.trainingEnd) { + if (!this.trainingStart) { return; } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index e2359466..32c10287 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -1,7 +1,9 @@ package de.tsschulz.tt_tagebuch.app.ui import android.content.ClipData +import android.content.Context import android.content.Intent +import android.media.MediaPlayer import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler @@ -96,6 +98,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.max import de.tsschulz.tt_tagebuch.app.AppDependencies +import de.tsschulz.tt_tagebuch.R import de.tsschulz.tt_tagebuch.app.pdf.sharePdfFile import de.tsschulz.tt_tagebuch.app.pdf.writeMembersPhoneListPdf import de.tsschulz.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf @@ -156,6 +159,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** Ab dieser Fensterbreite (dp): seitliche Navigation wie auf Tablet/Web. */ private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600 @@ -4004,6 +4010,36 @@ private fun DiaryDetailScreen( val planStartTimes = remember(sortedPlan, entry.trainingStart) { calculatePlanStartLabels(sortedPlan, entry.trainingStart) } + LaunchedEffect(entry.id, entry.trainingStart, entry.trainingEnd, sortedPlan, planStartTimes) { + val playedMarks = mutableSetOf() + while (true) { + val currentHHMM = currentMinuteLabel() + val trainingStart = entry.trainingStart?.take(5) + val trainingEnd = entry.trainingEnd?.take(5) + + listOfNotNull(trainingStart, trainingEnd).forEach { bellTime -> + val key = "bell:$bellTime" + if (currentHHMM == bellTime && playedMarks.add(key)) { + playRawSound(androidContext, R.raw.training_bell) + } + } + + if (trainingStart != null) { + sortedPlan + .mapNotNull { item -> planStartTimes[item.id]?.take(5) } + .filter { it != trainingStart } + .distinct() + .forEach { startTime -> + val key = "item:$startTime" + if (currentHHMM == startTime && playedMarks.add(key)) { + playRawSound(androidContext, R.raw.plan_item_start) + } + } + } + + delay(1_000) + } + } if (expandedPlanActionsItemId != null && sortedPlan.none { it.id == expandedPlanActionsItemId }) { expandedPlanActionsItemId = null } @@ -6816,22 +6852,79 @@ private fun DiaryPlanQuickTextAction( } private fun sameTrainingPlanScope(items: List, item: DiaryDateActivityItem): List { - return items.filter { it.groupId == item.groupId }.sortedWith(::diaryPlanItemComparator) + return items.sortedWith(::diaryPlanItemComparator) } private fun calculatePlanStartLabels(items: List, trainingStart: String?): Map { - var current = normalizeTime(trainingStart) + var globalCursor = normalizeTime(trainingStart) + val groupCursors = mutableMapOf() val result = linkedMapOf() items.forEach { item -> - current?.let { result[item.id] = it.take(5) } - val delta = item.duration - if (delta != null && delta > 0 && current != null) { - current = addMinutesToTime(current, delta) + val duration = item.duration?.takeIf { it > 0 } ?: 0 + val groupId = item.groupId + if (groupId == null) { + val start = latestTime(globalCursor, groupCursors.values) + start?.let { result[item.id] = it.take(5) } + val end = if (start != null && duration > 0) addMinutesToTime(start, duration) else start + globalCursor = end + if (end != null) { + groupCursors.keys.toList().forEach { gid -> + val cursor = groupCursors[gid] + if (isTimeAfter(end, cursor)) { + groupCursors[gid] = end + } + } + } + } else { + val start = groupCursors[groupId] ?: globalCursor + start?.let { result[item.id] = it.take(5) } + val end = if (start != null && duration > 0) addMinutesToTime(start, duration) else start + if (end != null) { + groupCursors[groupId] = end + } } } return result } +private fun currentMinuteLabel(): String { + return SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date()) +} + +private fun playRawSound(context: Context, resId: Int) { + runCatching { + MediaPlayer.create(context.applicationContext, resId)?.apply { + setOnCompletionListener { player -> player.release() } + setOnErrorListener { player, _, _ -> + player.release() + true + } + start() + } + } +} + +private fun latestTime(base: String?, values: Collection): String? { + var latest = base + values.forEach { candidate -> + if (isTimeAfter(candidate, latest)) latest = candidate + } + return latest +} + +private fun isTimeAfter(candidate: String?, current: String?): Boolean { + val c = timeToMinutes(candidate) ?: return false + val v = timeToMinutes(current) ?: return true + return c > v +} + +private fun timeToMinutes(value: String?): Int? { + val v = normalizeTime(value) ?: return null + val h = v.substring(0, 2).toIntOrNull() ?: return null + val m = v.substring(3, 5).toIntOrNull() ?: return null + return h * 60 + m +} + private fun normalizeTime(value: String?): String? { val v = value?.trim().orEmpty() if (v.length >= 5 && v[2] == ':') return if (v.length >= 8) v.substring(0, 8) else "$v:00" @@ -6878,11 +6971,7 @@ private fun DiaryPlanEditableCard( val title = item.displayTitle(timeblockLabel).ifBlank { "${tr("mobile.activityFallback", "Aktivität")} ${item.id}" } - val duration = when { - !item.durationText.isNullOrBlank() -> item.durationText - item.duration != null -> "${item.duration} min" - else -> null - } + val duration = item.durationText?.trim()?.takeIf { it.isNotEmpty() } val groupLine = item.planGroup?.name?.takeIf { it.isNotBlank() } ?: item.groupId?.let { gid -> planGroups.find { it.id == gid }?.name ?: "Gruppe $gid" } val showImageUrl = mainImageUrl ?: nestedImageUrl @@ -7000,6 +7089,7 @@ private fun DiaryPlanEditableCard( .forEach { ga -> val sub = ga.groupPredefinedActivity.displayLabel() val line = if (sub.isNotEmpty()) sub else "${tr("mobile.activityFallback", "Aktivität")} ${ga.id}" + val nestedDuration = ga.durationText?.trim()?.takeIf { it.isNotEmpty() } Row( modifier = Modifier.fillMaxWidth().padding(start = DiaryPlanColStart, top = 0.dp, bottom = 0.dp), verticalAlignment = Alignment.CenterVertically, @@ -7011,6 +7101,15 @@ private fun DiaryPlanEditableCard( maxLines = 2, overflow = TextOverflow.Ellipsis, ) + if (nestedDuration != null) { + Text( + nestedDuration, + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(DiaryPlanColDuration), + ) + } val nid = ga.id if (nid != null) { DiaryPlanQuickIconAction( diff --git a/mobile-app/composeApp/src/androidMain/res/raw/plan_item_start.mp3 b/mobile-app/composeApp/src/androidMain/res/raw/plan_item_start.mp3 new file mode 100644 index 00000000..9eb1ef6e Binary files /dev/null and b/mobile-app/composeApp/src/androidMain/res/raw/plan_item_start.mp3 differ diff --git a/mobile-app/composeApp/src/androidMain/res/raw/training_bell.mp3 b/mobile-app/composeApp/src/androidMain/res/raw/training_bell.mp3 new file mode 100644 index 00000000..3bccff4e Binary files /dev/null and b/mobile-app/composeApp/src/androidMain/res/raw/training_bell.mp3 differ diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDateActivityItem.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDateActivityItem.kt index 515a955d..a1559895 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDateActivityItem.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDateActivityItem.kt @@ -36,6 +36,8 @@ data class PredefinedActivitySummary( data class GroupActivitySummary( val id: Int? = null, val orderId: Int? = null, + val duration: Int? = null, + val durationText: String? = null, val groupPredefinedActivity: PredefinedActivitySummary? = null, )