From b2f84158ab5b10450a8043944d80f0ddc1d57847 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Mon, 7 Sep 2026 23:47:15 +0200 Subject: [PATCH 01/36] docs: add Neural Expressive redesign specification --- docs/design/neural-expressive-makeover.md | 155 ++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/design/neural-expressive-makeover.md diff --git a/docs/design/neural-expressive-makeover.md b/docs/design/neural-expressive-makeover.md new file mode 100644 index 0000000..89524d0 --- /dev/null +++ b/docs/design/neural-expressive-makeover.md @@ -0,0 +1,155 @@ +# JavaZone Android UI — Neural Expressive Makeover + +## Goal + +Redesign the JavaZone Android app around a compact, developer-focused conference experience inspired by Google's Neural Expressive / Material 3 Expressive principles. The timeline remains the primary surface; the redesign should improve scanability, hierarchy, navigation, and visual identity without sacrificing the useful conference information already present. + +## Product principles + +- **Timeline first:** a developer should be able to answer “what is happening now?”, “what is next?”, and “what else is happening in parallel?” at a glance. +- **Year is global context:** remove the separate Archive destination. `JavaZone 2026` in the top app bar becomes an interactive year selector. +- **No bulky bottom navigation:** use the available vertical space for schedule content. Secondary destinations are reached from the app bar and contextual actions. +- **Expressive, not decorative:** use color, shape, scale and motion to reinforce hierarchy and state rather than adding visual noise. +- **Information density with breathing room:** preserve useful metadata while reducing oversized controls and redundant chrome. +- **Accessible by default:** color must not be the only state indicator; maintain touch targets, contrast and large-font usability. + +## Proposed information architecture + +### Main timeline + +Top app bar: + +`JavaZone 2026 ▾ ♡ ⚙` + +Below it: + +1. Compact day selector. +2. Optional search/filter surface. +3. Timeline grouped by time slot. +4. Strong visual treatment for the current time and next session. + +### Year picker + +Tapping the year opens a modal/bottom-sheet style year selector containing current and historical years. Selecting a historical year switches the app's global year context and presents that year's available programme/archive content. There should no longer be a dedicated “Archive” tab. + +### Session detail + +Use a clear hierarchy: + +- format / time / room metadata +- prominent session title +- speakers +- favorite / calendar / reminder actions +- description +- video/resources when available + +## Visual direction + +### Palette + +Use a dark “ink” foundation with a restrained Java identity: + +- Ink background: deep navy/near-black +- Elevated surface: slightly lighter blue-black +- Java green: primary expressive accent +- Cyan/blue: standard talks and informational emphasis +- Coral/orange: high-energy or special states +- Violet: workshops +- Amber: lightning talks + +The existing semantic format/room colors should be consolidated into a small, centrally defined token system. Avoid using accent colors as arbitrary decoration. + +### Typography + +Introduce a deliberate type scale rather than relying almost entirely on Material defaults: + +- Display/title: expressive, compact and confident +- Session title: strong weight with controlled line height +- Metadata: compact and highly scannable +- Body: comfortable reading size +- Labels: small, medium/high weight, never overly condensed + +### Shapes + +Use a small shape vocabulary: + +- rounded cards for sessions +- pill-shaped compact filters/statuses +- larger rounded containers for important state such as “NOW” +- avoid excessive nesting of rounded rectangles + +### Motion + +Use subtle spring-like transitions for: + +- changing day/year +- expanding filters +- favorite state +- current-session emphasis +- sheet/dialog appearance + +Motion should communicate state and orientation, not delay the user. + +## Timeline redesign + +The timeline should visually distinguish: + +- **NOW** — current time marker and active session +- **NEXT** — nearest upcoming session +- **PAST** — reduced visual prominence +- **PARALLEL** — simultaneous sessions grouped naturally by time slot + +Keep the existing automatic scroll-to-current/upcoming behavior, but make the resulting state visually obvious. + +Session cards should prioritize: + +1. time / duration +2. title +3. room +4. format +5. speaker(s) +6. favorite state + +Avoid making every metadata item equally prominent. + +## Header/filter redesign + +The current combination of app bar, day selector and multiple horizontal chip rows consumes too much vertical space. Replace it with a compact hierarchy: + +- app bar with year selector and two or three high-value actions +- day selector +- a single filter/search control row +- active filters displayed only when needed + +Search and filters should be easy to reach but should not dominate the default schedule view. + +## Implementation notes + +The current application already contains useful architecture for year-specific archive loading, timeline grouping, favorites, filters, search, settings and session details. Reuse those behaviors where possible instead of rewriting business logic unnecessarily. + +The implementation should centralize visual decisions in `ui/theme` and introduce reusable components/tokens for: + +- app bar +- day selector +- filter controls +- session card +- timeline time marker +- format badge +- year picker +- session detail metadata + +Navigation should be simplified by removing the `Archive` destination and treating year selection as the mechanism for accessing historical programmes. + +## Acceptance criteria + +- No large bottom navigation bar on the main experience. +- Historical years remain accessible without losing the existing archive functionality. +- `JavaZone ` is an interactive global year selector. +- Timeline remains the primary and fastest way to consume the programme. +- Header occupies substantially less vertical space. +- Parallel sessions remain easy to compare. +- Current/upcoming sessions are immediately recognizable. +- Existing search, filters, favorites, reminders, settings and session detail functionality remain available. +- Light and dark themes remain coherent. +- Accessibility and large-font layouts are considered. +- No business/data-layer behavior is changed unless required by the navigation redesign. From 36fcb9f78a48a41a60cc09fd40accce9557616a2 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Mon, 7 Sep 2026 23:48:32 +0200 Subject: [PATCH 02/36] feat: remove persistent bottom navigation --- .../com/olavbg/javazone/ui/JavaZoneApp.kt | 80 ++++++------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt index 2edd61a..5c8ceca 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt @@ -1,17 +1,8 @@ package com.olavbg.javazone.ui import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberNavBackStack @@ -30,7 +21,6 @@ import com.olavbg.javazone.ui.timeline.TimelineScreen import com.olavbg.javazone.ui.timeline.TimelineViewModel import com.olavbg.javazone.ui.timeline.TimelineViewModelFactory -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun JavaZoneApp( repository: SessionRepository, @@ -44,68 +34,43 @@ fun JavaZoneApp( val backStack = if (initialSessionId != null) { rememberNavBackStack( - NavDestination.Timeline, NavDestination.SessionDetail(initialSessionId) + NavDestination.Timeline, + NavDestination.SessionDetail(initialSessionId) ) } else { rememberNavBackStack(NavDestination.Timeline) } - Scaffold( - bottomBar = { - val currentEntry = backStack.lastOrNull() - NavigationBar { - NavigationBarItem( - selected = currentEntry == NavDestination.Timeline, - onClick = { - if (currentEntry != NavDestination.Timeline) { - backStack.clear() - backStack.add(NavDestination.Timeline) - } - }, - icon = { Icon(Icons.Default.Schedule, contentDescription = "Tidslinje") }, - label = { Text("Tidslinje") }) - NavigationBarItem( - selected = currentEntry == NavDestination.Archive, - onClick = { - if (currentEntry != NavDestination.Archive) { - backStack.clear() - backStack.add(NavDestination.Archive) - } - }, - icon = { Icon(Icons.Default.History, contentDescription = "Arkiv") }, - label = { Text("Arkiv") }) - } - }) { innerPadding -> + // The schedule is the primary product surface. Historical programmes will be + // exposed through the year selector rather than a persistent bottom navigation item. + Scaffold { innerPadding -> NavDisplay( backStack = backStack, sceneStrategies = listOf(SinglePaneSceneStrategy()), - modifier = Modifier.fillMaxSize(), + modifier = androidx.compose.ui.Modifier.fillMaxSize(), entryProvider = { key -> when (key) { - NavDestination.Timeline -> NavEntry( - key = key - ) { + NavDestination.Timeline -> NavEntry(key = key) { TimelineScreen( - viewModel = timelineViewModel, onSessionClick = { id -> - backStack.add(NavDestination.SessionDetail(id)) - }, onSettingsClick = { - backStack.add(NavDestination.Settings) - }, contentPadding = innerPadding + viewModel = timelineViewModel, + onSessionClick = { id -> + backStack.add(NavDestination.SessionDetail(id)) + }, + onSettingsClick = { + backStack.add(NavDestination.Settings) + }, + contentPadding = innerPadding ) } - NavDestination.Archive -> NavEntry( - key = key - ) { + NavDestination.Archive -> NavEntry(key = key) { ArchiveScreen( viewModel = viewModel(factory = ArchiveViewModelFactory(repository)), contentPadding = innerPadding ) } - is NavDestination.SessionDetail -> NavEntry( - key = key - ) { + is NavDestination.SessionDetail -> NavEntry(key = key) { SessionDetailScreen( sessionId = key.sessionId, repository = repository, @@ -119,13 +84,13 @@ fun JavaZoneApp( ) } - NavDestination.Settings -> NavEntry( - key = key - ) { + NavDestination.Settings -> NavEntry(key = key) { SettingsScreen( viewModel = viewModel( factory = SettingsViewModelFactory( - settingsRepository, repository, reminderManager + settingsRepository, + repository, + reminderManager ) ), onBackClick = { backStack.removeAt(backStack.size - 1) }, @@ -135,6 +100,7 @@ fun JavaZoneApp( else -> NavEntry(key) { } } - }) + } + ) } } From ae8de906b4235eb590b317a9fc184bde15f59a52 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Mon, 7 Sep 2026 23:48:36 +0200 Subject: [PATCH 03/36] refactor: establish expressive JavaZone color tokens --- .../com/olavbg/javazone/ui/theme/Color.kt | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/theme/Color.kt b/app/src/main/java/com/olavbg/javazone/ui/theme/Color.kt index c573398..ddfe950 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/theme/Color.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/theme/Color.kt @@ -2,27 +2,37 @@ package com.olavbg.javazone.ui.theme import androidx.compose.ui.graphics.Color -val FreshGreen = Color(0xFF00E676) -val DeepBlue = Color(0xFF1E3A8A) -val DarkBackground = Color(0xFF0F172A) // Rich slate navy background -val DarkSurface = Color(0xFF1E293B) // Elevated slate card surface -val DarkSurfaceVariant = Color(0xFF334155) // Border/Header variant -val DarkOnSurface = Color(0xFFF8FAFC) +// JavaZone design tokens. Keep semantic roles here so the UI does not invent +// one-off colors in individual screens. +val JavaGreen = Color(0xFF63F5A5) +val JavaGreenStrong = Color(0xFF00D978) +val JavaInk = Color(0xFF081018) +val JavaInkElevated = Color(0xFF101C26) +val JavaInkContainer = Color(0xFF172733) +val JavaText = Color(0xFFF2F7F5) +val JavaTextMuted = Color(0xFF9EAFAB) -val JavaBlue = Color(0xFF2563EB) -val JavaOrange = Color(0xFFF97316) +val JavaBlue = Color(0xFF5CC8FF) +val JavaOrange = Color(0xFFFF8A65) +val WorkshopPurple = Color(0xFFC18CFF) +val LightningAmber = Color(0xFFFFC857) +val PresentationBlue = Color(0xFF75BFFF) -val WorkshopPurple = Color(0xFFA855F7) -val LightningAmber = Color(0xFFF59E0B) -val PresentationBlue = Color(0xFF60A5FA) // Lighter blue for better contrast +// Legacy names retained for components that have not yet migrated to semantic tokens. +val FreshGreen = JavaGreenStrong +val DeepBlue = Color(0xFF123B35) +val DarkBackground = JavaInk +val DarkSurface = JavaInkElevated +val DarkSurfaceVariant = JavaInkContainer +val DarkOnSurface = JavaText val RoomAccentColors = listOf( - Color(0xFF38BDF8), - Color(0xFF34D399), - Color(0xFFFB923C), - Color(0xFFE879F9), - Color(0xFFF87171), - Color(0xFF4ADE80), - Color(0xFFFBBF24), - Color(0xFF60A5FA) + Color(0xFF5CC8FF), + Color(0xFF63F5A5), + Color(0xFFFF9B71), + Color(0xFFD29BFF), + Color(0xFFFF7777), + Color(0xFF72E6A3), + Color(0xFFFFD166), + Color(0xFF7DB9FF) ) From bb196918b3632d6cd0902578c037faa40485f4c3 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Mon, 7 Sep 2026 23:48:42 +0200 Subject: [PATCH 04/36] refactor: apply expressive JavaZone theme --- .../com/olavbg/javazone/ui/theme/Theme.kt | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/theme/Theme.kt b/app/src/main/java/com/olavbg/javazone/ui/theme/Theme.kt index 4123ae0..89a6e04 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/theme/Theme.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/theme/Theme.kt @@ -1,48 +1,56 @@ package com.olavbg.javazone.ui.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( - primary = FreshGreen, + primary = JavaGreen, + onPrimary = Color(0xFF00391D), + primaryContainer = Color(0xFF07552F), + onPrimaryContainer = Color(0xFFB9FFD1), secondary = JavaBlue, + onSecondary = Color(0xFF003547), + secondaryContainer = Color(0xFF12485D), + onSecondaryContainer = Color(0xFFB8E9FF), tertiary = JavaOrange, - background = DarkBackground, - surface = DarkSurface, - onPrimary = Color.Black, - onSecondary = Color.White, - onTertiary = Color.Black, - onBackground = DarkOnSurface, - onSurface = DarkOnSurface, - surfaceVariant = DarkSurfaceVariant, - onSurfaceVariant = Color(0xFF94A3B8), - secondaryContainer = Color(0xFF1E3A8A), - onSecondaryContainer = Color(0xFFDBEAFE), + onTertiary = Color(0xFF4D190D), + tertiaryContainer = Color(0xFF71301F), + onTertiaryContainer = Color(0xFFFFDAD0), + background = JavaInk, + onBackground = JavaText, + surface = JavaInkElevated, + onSurface = JavaText, + surfaceVariant = JavaInkContainer, + onSurfaceVariant = JavaTextMuted, + outline = Color(0xFF465B63), + outlineVariant = Color(0xFF263942), ) private val LightColorScheme = lightColorScheme( - primary = DeepBlue, - secondary = FreshGreen, - tertiary = JavaOrange, - background = Color(0xFFF1F5F9), - surface = Color.White, + primary = Color(0xFF006B3C), onPrimary = Color.White, - onSecondary = Color.Black, - onTertiary = Color.Black, - onBackground = Color(0xFF0F172A), - onSurface = Color(0xFF0F172A), - surfaceVariant = Color(0xFFE2E8F0), - onSurfaceVariant = Color(0xFF64748B), - secondaryContainer = Color(0xFFDBEAFE), - onSecondaryContainer = Color(0xFF1E3A8A), + primaryContainer = Color(0xFF8BFFB8), + onPrimaryContainer = Color(0xFF002110), + secondary = Color(0xFF006782), + onSecondary = Color.White, + secondaryContainer = Color(0xFFB8EAFF), + onSecondaryContainer = Color(0xFF001F2A), + tertiary = Color(0xFF9D3B20), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFFDBD0), + onTertiaryContainer = Color(0xFF3B0A01), + background = Color(0xFFF5FAF8), + onBackground = Color(0xFF101916), + surface = Color(0xFFF5FAF8), + onSurface = Color(0xFF101916), + surfaceVariant = Color(0xFFDCE7E3), + onSurfaceVariant = Color(0xFF40504B), + outline = Color(0xFF6F7F79), + outlineVariant = Color(0xFFBECAC5), ) @Composable @@ -51,15 +59,10 @@ fun JavaZoneTheme( dynamicColor: Boolean = false, content: @Composable () -> Unit ) { - val colorScheme = when { - (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + // JavaZone deliberately keeps its own visual identity rather than adopting + // arbitrary device wallpaper colors. This makes the programme semantics + // predictable across devices and preserves the JavaZone brand language. + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, @@ -67,4 +70,4 @@ fun JavaZoneTheme( shapes = Shapes, content = content ) -} \ No newline at end of file +} From 8efbfc435055514f763e36f758fc6c9aeec040ef Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Mon, 7 Sep 2026 23:48:48 +0200 Subject: [PATCH 05/36] refactor: add compact expressive typography scale --- .../java/com/olavbg/javazone/ui/theme/Type.kt | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/theme/Type.kt b/app/src/main/java/com/olavbg/javazone/ui/theme/Type.kt index 578e919..740c7f7 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/theme/Type.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/theme/Type.kt @@ -6,29 +6,69 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with +// A compact, high-contrast type scale for dense conference information. val Typography = Typography( + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.ExtraBold, + fontSize = 36.sp, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.ExtraBold, + fontSize = 28.sp, + lineHeight = 32.sp, + letterSpacing = (-0.25).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.sp + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( + letterSpacing = 0.sp + ), + bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, + fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.sp ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.3.sp + ), labelSmall = TextStyle( fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Bold, fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp + lineHeight = 14.sp, + letterSpacing = 0.4.sp ) - */ -) \ No newline at end of file +) From b6a36f6c4f245521f654868dff881ca27e8e9a6e Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 18:59:32 +0200 Subject: [PATCH 06/36] feat: redesign timeline and year-aware schedule navigation --- .../javazone/ui/timeline/TimelineScreen.kt | 933 +++--------------- 1 file changed, 150 insertions(+), 783 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt index 32662c1..a908d9e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt @@ -1,30 +1,32 @@ package com.olavbg.javazone.ui.timeline -import androidx.compose.animation.* -import androidx.compose.animation.core.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.EventBusy +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -33,855 +35,220 @@ import androidx.compose.ui.unit.sp import com.olavbg.javazone.model.Session import com.olavbg.javazone.ui.components.FormatBadge import com.olavbg.javazone.ui.components.RoomTag -import com.olavbg.javazone.ui.theme.* -import com.olavbg.javazone.util.* import java.time.Duration import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.math.abs +private val DayOptions = listOf(null, "Wednesday", "Thursday") -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun TimelineScreen( viewModel: TimelineViewModel, onSessionClick: (String) -> Unit, onSettingsClick: () -> Unit, + onYearClick: () -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { val sessions by viewModel.sessions.collectAsState() val groupedSessions by viewModel.groupedSessions.collectAsState() val isLoading by viewModel.isLoading.collectAsState() - val availableRooms by viewModel.availableRooms.collectAsState() - val availableFormats by viewModel.availableFormats.collectAsState() - val availableLanguages by viewModel.availableLanguages.collectAsState() - val currentTime by viewModel.currentTime.collectAsState() val selectedDay by viewModel.selectedDay.collectAsState() + val selectedYear by viewModel.selectedYear.collectAsState() val onlyFavorites by viewModel.onlyFavorites.collectAsState() val selectedFormat by viewModel.selectedFormat.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() val selectedRoom by viewModel.selectedRoom.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() + val currentTime by viewModel.currentTime.collectAsState() + val availableFormats by viewModel.availableFormats.collectAsState() + val availableLanguages by viewModel.availableLanguages.collectAsState() + val availableRooms by viewModel.availableRooms.collectAsState() val filterSpeaker by viewModel.filterSpeaker.collectAsState() - - var isSearchVisible by remember { mutableStateOf(value = false) } - - val roomsList = remember(groupedSessions, availableRooms) { - availableRooms.ifEmpty { - groupedSessions.values.flatten().asSequence().map { it.room }.filter { it.isNotBlank() }.distinct().sortedWith(compareBy({ extractRoomNumber(it) }, { it })).toList() - } - } - + var searchVisible by rememberSaveable { mutableStateOf(false) } + var filtersVisible by rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() - // Auto-scroll to active or upcoming time slot when day changes or data first arrives - LaunchedEffect(groupedSessions, selectedDay) { + LaunchedEffect(groupedSessions, selectedDay, selectedYear) { if (groupedSessions.isNotEmpty() && viewModel.shouldScrollToNow()) { - val activeIndex = findFirstActiveOrUpcomingIndex(groupedSessions, currentTime) - if (activeIndex > 0) { - listState.scrollToItem(activeIndex) - } + listState.scrollToItem(findFirstActiveOrUpcomingIndex(groupedSessions, currentTime)) viewModel.markScrolledToNow() } } Scaffold( - topBar = { - TimelineHeader( - selectedDay = selectedDay, - onDaySelected = viewModel::setDay, - onlyFavorites = onlyFavorites, - onFavoritesToggled = viewModel::setOnlyFavorites, - selectedFormat = selectedFormat, - onFormatSelected = viewModel::setFormat, - availableFormats = availableFormats, - selectedLanguage = selectedLanguage, - onLanguageSelected = viewModel::setLanguage, - availableLanguages = availableLanguages, - selectedRoom = selectedRoom, - onRoomSelected = viewModel::setRoom, - availableRooms = roomsList, - searchQuery = searchQuery, - onSearchQueryChange = viewModel::setSearchQuery, - isSearchVisible = isSearchVisible, - onToggleSearch = { - isSearchVisible = !isSearchVisible - if (!isSearchVisible) viewModel.setSearchQuery("") - }, - onSettingsClick = onSettingsClick, - ) - }, modifier = modifier, - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = MaterialTheme.colorScheme.primary - ) - } else { - Column( - modifier = Modifier.fillMaxSize() - ) { - if (filterSpeaker != null) { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Foredrag av $filterSpeaker", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = { viewModel.setFilterSpeaker(null) }) { - Icon(Icons.Default.Close, contentDescription = "Fjern filter") - } + topBar = { + Column { + Surface(tonalElevation = 1.dp) { + TopAppBar( + title = { + TextButton(onClick = onYearClick, contentPadding = PaddingValues(horizontal = 4.dp)) { + Text("JavaZone", fontWeight = FontWeight.Black, style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.width(6.dp)) + Text("$selectedYear", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text(" ▾", color = MaterialTheme.colorScheme.onSurfaceVariant) } - } - } - - if (sessions.isEmpty()) { - EmptyStateView( - isSearchActive = (searchQuery.isNotBlank() || onlyFavorites || selectedFormat != null || selectedRoom != null), - onClearFilters = { - viewModel.setSearchQuery("") - viewModel.setOnlyFavorites(false) - viewModel.setFormat(null) - viewModel.setLanguage(null) - viewModel.setRoom(null) + }, + actions = { + IconButton(onClick = { searchVisible = !searchVisible; if (!searchVisible) viewModel.setSearchQuery("") }) { + Icon(if (searchVisible) Icons.Default.Clear else Icons.Default.Search, "Søk") } - ) - } else { - AgendaListView( - groupedSessions = groupedSessions, - currentTime = currentTime, - listState = listState, - onSessionClick = onSessionClick, - onFavoriteClick = viewModel::toggleFavorite, - contentPadding = PaddingValues( - bottom = 32.dp + contentPadding.calculateBottomPadding() - ) - ) - } - } - } - } - } -} -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TimelineHeader( - selectedDay: String?, - onDaySelected: (String?) -> Unit, - onlyFavorites: Boolean, - onFavoritesToggled: (Boolean) -> Unit, - selectedFormat: String?, - onFormatSelected: (String?) -> Unit, - availableFormats: List, - selectedLanguage: String?, - onLanguageSelected: (String?) -> Unit, - availableLanguages: List, - selectedRoom: String?, - onRoomSelected: (String?) -> Unit, - availableRooms: List, - searchQuery: String, - onSearchQueryChange: (String) -> Unit, - isSearchVisible: Boolean, - onToggleSearch: () -> Unit, - onSettingsClick: () -> Unit -) { - Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 1.dp - ) { - Column { - TopAppBar( - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "JavaZone", - fontWeight = FontWeight.Black, - style = MaterialTheme.typography.titleLarge - ) - Spacer(modifier = Modifier.width(6.dp)) - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primaryContainer - ) { - Text( - text = "2026", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - }, - actions = { - IconButton(onClick = onToggleSearch) { - Icon( - imageVector = if (isSearchVisible) Icons.Default.Close else Icons.Outlined.Search, - contentDescription = "Søk" - ) - } - IconButton(onClick = { onFavoritesToggled(!onlyFavorites) }) { - Icon( - imageVector = if (onlyFavorites) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - contentDescription = "Favoritter", - tint = if (onlyFavorites) MaterialTheme.colorScheme.tertiary else LocalContentColor.current - ) - } - IconButton(onClick = onSettingsClick) { - Icon(Icons.Rounded.Settings, contentDescription = "Innstillinger") - } - }, - windowInsets = WindowInsets.statusBars - ) - - // Search Bar - AnimatedVisibility( - visible = isSearchVisible, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - OutlinedTextField( - value = searchQuery, - onValueChange = onSearchQueryChange, - placeholder = { Text("Søk tittel, foredragsholder, rom...") }, - leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChange("") }) { - Icon(Icons.Default.Clear, contentDescription = "Nullstill") + IconButton(onClick = { viewModel.setOnlyFavorites(!onlyFavorites) }) { + Icon(if (onlyFavorites) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritter", tint = if (onlyFavorites) MaterialTheme.colorScheme.tertiary else LocalContentColor.current) } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - - // Day & View Selector Row - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) { - SegmentedButton( - selected = selectedDay == null, - onClick = { onDaySelected(null) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3) - ) { - Text("Alle dager", fontSize = 11.sp) - } - SegmentedButton( - selected = selectedDay == "Wednesday", - onClick = { onDaySelected("Wednesday") }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3) - ) { - Text("Onsdag", fontSize = 11.sp) - } - SegmentedButton( - selected = selectedDay == "Thursday", - onClick = { onDaySelected("Thursday") }, - shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3) - ) { - Text("Torsdag", fontSize = 11.sp) - } - } - } - - // Dynamic Quick Filter Chips (Format, Language, Room) - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (onlyFavorites) { - item { - FilterChip( - selected = true, - onClick = { onFavoritesToggled(false) }, - label = { Text("Favoritter") }, - leadingIcon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(14.dp)) }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onTertiaryContainer - ) - ) - } + IconButton(onClick = onSettingsClick) { Icon(Icons.Rounded.Settings, "Innstillinger") } + }, + windowInsets = WindowInsets.statusBars + ) } - // Show format chips dynamically available for the selected day - items(availableFormats) { format -> - val isSelected = selectedFormat?.equals(format, ignoreCase = true) == true - FilterChip( - selected = isSelected, - onClick = { onFormatSelected(if (isSelected) null else format) }, - label = { - Text( - when { - format.contains("presentation", ignoreCase = true) -> "Foredrag" - format.contains("lightning", ignoreCase = true) -> "Lynforedrag" - format.contains("workshop", ignoreCase = true) -> "Workshop" - else -> format - } - ) - } + AnimatedVisibility(searchVisible, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + OutlinedTextField( + value = searchQuery, + onValueChange = viewModel::setSearchQuery, + placeholder = { Text("Søk foredrag, foredragsholder eller rom…") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = if (searchQuery.isNotEmpty()) ({ IconButton(onClick = { viewModel.setSearchQuery("") }) { Icon(Icons.Default.Clear, "Nullstill") } }) else null, + singleLine = true, + shape = RoundedCornerShape(18.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp) ) } - // Show language chips dynamically available for the selected day - items(availableLanguages) { lang -> - val isSelected = selectedLanguage == lang - FilterChip( - selected = isSelected, - onClick = { onLanguageSelected(if (isSelected) null else lang) }, - label = { Text(if (lang.contains("no", ignoreCase = true)) "🇳🇴 NO" else "🇬🇧 EN") } - ) + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { + DayOptions.forEachIndexed { index, day -> + SegmentedButton( + selected = selectedDay == day, + onClick = { viewModel.setDay(day) }, + shape = SegmentedButtonDefaults.itemShape(index, DayOptions.size) + ) { Text(dayLabel(day), fontSize = 12.sp, maxLines = 1) } + } + } + Spacer(Modifier.width(8.dp)) + IconButton(onClick = { filtersVisible = !filtersVisible }) { + BadgedBox(badge = { if (viewModel.activeFilterCount() > 0) Badge { Text(viewModel.activeFilterCount().toString()) } }) { + Icon(Icons.Default.FilterList, "Filtre") + } + } } - // Show room chips dynamically available for the selected day - if (availableRooms.isNotEmpty()) { - items(availableRooms) { room -> - val isSelected = selectedRoom == room - FilterChip( - selected = isSelected, - onClick = { onRoomSelected(if (isSelected) null else room) }, - label = { Text(room) } - ) + AnimatedVisibility(filtersVisible || viewModel.activeFilterCount() > 0) { + LazyRow(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onlyFavorites) item { FilterChip(true, { viewModel.setOnlyFavorites(false) }, label = { Text("Favoritter") }) } + items(availableFormats) { format -> FilterChip(selectedFormat.equals(format, true), { viewModel.setFormat(if (selectedFormat.equals(format, true)) null else format) }, label = { Text(formatLabel(format)) }) } + items(availableLanguages) { lang -> FilterChip(selectedLanguage == lang, { viewModel.setLanguage(if (selectedLanguage == lang) null else lang) }, label = { Text(languageLabel(lang)) }) } + items(availableRooms) { room -> FilterChip(selectedRoom == room, { viewModel.setRoom(if (selectedRoom == room) null else room) }, label = { Text(room) }) } } } } } - } -} + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + if (isLoading) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } else if (sessions.isEmpty()) { + EmptyStateView(searchQuery.isNotBlank() || viewModel.activeFilterCount() > 0, viewModel::clearFilters) + } else { + TimelineList(groupedSessions, currentTime, listState, onSessionClick, viewModel::toggleFavorite, contentPadding) + } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun AgendaListView( - groupedSessions: Map>, - currentTime: Instant, - listState: androidx.compose.foundation.lazy.LazyListState, - onSessionClick: (String) -> Unit, - onFavoriteClick: (Session) -> Unit, - contentPadding: PaddingValues = PaddingValues(bottom = 32.dp) -) { - Box(modifier = Modifier.fillMaxSize()) { - // Continuous Vertical Guide Line (positioned at x = 36.dp) - Box( - modifier = Modifier - .padding(start = 36.dp) - .width(2.dp) - .fillMaxHeight() - .background( - Brush.verticalGradient( - listOf( - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), - MaterialTheme.colorScheme.surfaceVariant, - MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - ) - ) + if (filterSpeaker != null) { + AssistChip( + onClick = { viewModel.setFilterSpeaker(null) }, + label = { Text("Foredrag av $filterSpeaker") }, + leadingIcon = { Icon(Icons.Default.Person, null, Modifier.size(16.dp)) }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp) ) - ) - - LazyColumn( - state = listState, - contentPadding = contentPadding, - modifier = Modifier.fillMaxSize() - ) { - groupedSessions.forEach { (timeSlot, sessionsAtTime) -> - val isLiveSlot = sessionsAtTime.any { isSessionActive(it, currentTime) } - - // Sticky Header: Time Node Badge pinned to top-left (shown once for group) - stickyHeader(key = "agenda-sticky-$timeSlot") { - TimelineStickyTimeHeader( - timeSlot = timeSlot, - isLiveSlot = isLiveSlot, - sessionCount = sessionsAtTime.size - ) - } - - // Session Rows in this Time Block (sorted by room number) - itemsIndexed( - items = sessionsAtTime, - key = { _, session -> "agenda-item-${session.id}" }, - contentType = { _, _ -> "session-row" } - ) { _, session -> - TimelineSessionRow( - session = session, - isPast = isSessionPast(session.endTimeZulu, currentTime), - isActive = isSessionActive(session, currentTime), - currentTime = currentTime, - onFavoriteClick = { onFavoriteClick(session) }, - onClick = { onSessionClick(session.id) } - ) - } } } } } @Composable -fun TimelineStickyTimeHeader( - timeSlot: String, - isLiveSlot: Boolean, - sessionCount: Int +private fun TimelineList( + grouped: Map>, + currentTime: Instant, + listState: androidx.compose.foundation.lazy.LazyListState, + onSessionClick: (String) -> Unit, + onFavoriteClick: (Session) -> Unit, + contentPadding: PaddingValues ) { - Surface( - color = MaterialTheme.colorScheme.background.copy(alpha = 0.95f), - tonalElevation = 2.dp, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 4.dp, start = 16.dp, end = 16.dp) - ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = if (isLiveSlot) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primaryContainer, - border = BorderStroke( - 1.dp, - if (isLiveSlot) MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) - ), - shadowElevation = if (isLiveSlot) 2.dp else 0.dp - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = timeSlot, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Black, - color = if (isLiveSlot) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onPrimaryContainer - ) - if (isLiveSlot) { - Spacer(modifier = Modifier.width(6.dp)) - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary) - ) + LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 32.dp + contentPadding.calculateBottomPadding()), modifier = Modifier.fillMaxSize()) { + grouped.forEach { (time, sessions) -> + val live = sessions.any { isActive(it, currentTime) } + stickyHeader(key = "time-$time") { + Surface(color = MaterialTheme.colorScheme.background.copy(alpha = .96f)) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(14.dp), color = if (live) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant) { + Text(time, Modifier.padding(horizontal = 10.dp, vertical = 5.dp), fontWeight = FontWeight.Black, color = if (live) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) } + Spacer(Modifier.width(10.dp)) + Text(if (sessions.size == 1) "1 foredrag" else "${sessions.size} parallelle", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + if (live) AssistChip(onClick = {}, label = { Text("NÅ") }, enabled = false) } } - - Spacer(modifier = Modifier.width(12.dp)) - - HorizontalDivider( - color = if (isLiveSlot) MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = "$sessionCount ${if (sessionCount == 1) "foredrag" else "parallell-løp"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium - ) } - - // Just a bit of vertical line spacing - Spacer(modifier = Modifier.height(8.dp)) - } - } -} - -/** - * Timeline Session Row - * Features: - * - Vertically centered timeline connector node aligned with card height - */ -@Composable -fun TimelineSessionRow( - session: Session, - isPast: Boolean, - isActive: Boolean, - currentTime: Instant, - onFavoriteClick: () -> Unit, - onClick: () -> Unit -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - // Left Column Node Connector (Centered at x = 36.dp) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.width(52.dp) - ) { - // Horizontal Connector Arm from vertical line to card - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Spacer(modifier = Modifier.width(36.dp)) - Box( - modifier = Modifier - .height(2.dp) - .weight(1f) - .background( - if (isActive) MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - ) - ) + items(sessions, key = { it.id }) { session -> + val active = isActive(session, currentTime) + val past = isPast(session, currentTime) + SessionCard(session, active, past, currentTime, onFavoriteClick, { onSessionClick(session.id) }) } - - // Connector Dot on the vertical line (vertically centered with card) - Box( - modifier = Modifier - .size(if (isActive) 12.dp else 8.dp) - .clip(CircleShape) - .background( - when { - isActive -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f) - isPast -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - else -> FreshGreen - } - ) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.background, - shape = CircleShape - ) - ) - } - - // Right Column: Session Card - Box( - modifier = Modifier - .weight(1f) - .padding(end = 16.dp) - ) { - DetailedSessionCard( - session = session, - isPast = isPast, - isActive = isActive, - currentTime = currentTime, - onFavoriteClick = onFavoriteClick, - onClick = onClick, - modifier = Modifier.fillMaxWidth() - ) } } } @Composable -fun DetailedSessionCard( - session: Session, - isPast: Boolean, - isActive: Boolean, - currentTime: Instant, - onFavoriteClick: () -> Unit, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val alpha by animateFloatAsState(if (isPast) 0.55f else 1f, label = "alpha") - - Card( - onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = if (isActive) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = if (isActive) 3.dp else 1.dp), - shape = RoundedCornerShape(14.dp), - modifier = modifier - .alpha(alpha) - .border( - width = if (isActive) 1.5.dp else 0.5.dp, - color = if (isActive) MaterialTheme.colorScheme.secondary.copy(alpha = 0.6f) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), - shape = RoundedCornerShape(14.dp) - ) - ) { - Column(modifier = Modifier.padding(14.dp)) { - val durationMins = remember(session) { calculateSessionDurationMinutes(session) } - +private fun SessionCard(session: Session, active: Boolean, past: Boolean, currentTime: Instant, onFavoriteClick: (Session) -> Unit, onClick: () -> Unit) { + val alpha by androidx.compose.animation.core.animateFloatAsState(if (past) .48f else 1f, label = "pastAlpha") + Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 5.dp).alpha(alpha), shape = RoundedCornerShape(if (active) 24.dp else 18.dp), colors = CardDefaults.cardColors(containerColor = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow), border = if (active) BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = .7f)) else null) { + Column(Modifier.padding(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - // Duration Tag (e.g. 60 min, 10 min) - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - border = BorderStroke(1.dp, if (isActive) MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)) - ) { - Text( - text = "$durationMins min", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - RoomTag(room = session.room) - Spacer(modifier = Modifier.width(8.dp)) - FormatBadge(format = session.format) - if (session.language != null) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (session.language.contains("no", ignoreCase = true)) "🇳🇴" else "🇬🇧", - fontSize = 13.sp - ) - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = onFavoriteClick, modifier = Modifier.size(28.dp)) { - Icon( - imageVector = if (session.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - contentDescription = null, - tint = if (session.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current, - modifier = Modifier.size(18.dp) - ) - } + Text("${formatTime(session.startTimeZulu)}–${formatTime(session.endTimeZulu)}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + RoomTag(session.room) + Spacer(Modifier.width(6.dp)) + FormatBadge(session.format) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { onFavoriteClick(session) }, modifier = Modifier.size(32.dp)) { Icon(if (session.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritt", tint = if (session.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current, modifier = Modifier.size(18.dp)) } } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = session.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - + Spacer(Modifier.height(8.dp)) + Text(session.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) if (session.speakers.isNotEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = session.speakers.joinToString { it.name }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Spacer(Modifier.height(5.dp)) + Text(session.speakers.joinToString { it.name }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) } - - if (isActive) { - Spacer(modifier = Modifier.height(10.dp)) - val progress = calculateSessionProgress(session, currentTime) - val remainingMins = calculateRemainingMinutes(session, currentTime) - - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f), - trackColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f), - strokeCap = StrokeCap.Round, - gapSize = 0.dp, - drawStopIndicator = {} - ) - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "${formatTime(session.startTimeZulu)} – ${formatTime(session.endTimeZulu)}", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = if (remainingMins > 0) "$remainingMins min igjen" else "Avsluttes nå", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } else if (!isPast) { - val minsUntil = calculateMinutesUntilStart(session, currentTime) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${formatTime(session.startTimeZulu)} – ${formatTime(session.endTimeZulu)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 11.sp - ) - if (minsUntil in 1..60) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) - ) { - Text( - text = "Om $minsUntil min", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } + if (active) { + Spacer(Modifier.height(10.dp)) + val progress = progress(session, currentTime) + LinearProgressIndicator({ progress }, Modifier.fillMaxWidth().height(5.dp), strokeCap = StrokeCap.Round) + Spacer(Modifier.height(5.dp)) + Text("NÅ · ${remaining(session, currentTime)} min igjen", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) } } } } - @Composable -fun EmptyStateView( - isSearchActive: Boolean, - onClearFilters: () -> Unit -) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .padding(32.dp) - ) { +private fun EmptyStateView(active: Boolean, clear: () -> Unit) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = Icons.Default.EventBusy, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = if (isSearchActive) "Ingen foredrag passer søket/filteret" else "Ingen foredrag funnet", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (isSearchActive) "Prøv å endre på søkeordene eller tilbakestill filtrene." else "Sjekk internettforbindelsen eller prøv igjen senere.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - if (isSearchActive) { - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onClearFilters) { - Text("Nullstill alle filter") - } - } + Icon(Icons.Default.EventBusy, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .5f)) + Spacer(Modifier.height(16.dp)) + Text(if (active) "Ingen foredrag passer filteret" else "Ingen foredrag funnet", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (active) { Spacer(Modifier.height(12.dp)); FilledTonalButton(onClick = clear) { Text("Nullstill filtre") } } } } } - -private fun isSessionActive(session: Session, currentTime: Instant): Boolean { - return try { - val start = Instant.parse(session.startTimeZulu) - val end = Instant.parse(session.endTimeZulu) - (currentTime.isAfter(start) || currentTime == start) && currentTime.isBefore(end) - } catch (_: Exception) { - false - } -} - -private fun isSessionPast(endTimeZulu: String, currentTime: Instant): Boolean { - return try { - val end = Instant.parse(endTimeZulu) - currentTime.isAfter(end) - } catch (_: Exception) { - false - } -} - -private fun calculateSessionProgress(session: Session, currentTime: Instant): Float { - return try { - val start = Instant.parse(session.startTimeZulu) - val end = Instant.parse(session.endTimeZulu) - val total = Duration.between(start, end).toMillis() - val elapsed = Duration.between(start, currentTime).toMillis() - if (total <= 0) 0f else (elapsed.toFloat() / total.toFloat()).coerceIn(0f, 1f) - } catch (_: Exception) { - 0f - } -} - -private fun calculateRemainingMinutes(session: Session, currentTime: Instant): Long { - return try { - val end = Instant.parse(session.endTimeZulu) - val remaining = Duration.between(currentTime, end).toMinutes() - if (remaining < 0) 0 else remaining - } catch (_: Exception) { - 0 - } -} - - -private fun calculateMinutesUntilStart(session: Session, currentTime: Instant): Long { - return try { - val start = Instant.parse(session.startTimeZulu) - val minutes = Duration.between(currentTime, start).toMinutes() - if (minutes < 0) 0 else minutes - } catch (_: Exception) { - 0 - } -} - -private fun extractRoomNumber(room: String): Int { - val digits = room.filter { it.isDigit() } - return digits.toIntOrNull() ?: Int.MAX_VALUE -} - -private fun findFirstActiveOrUpcomingIndex( - groupedSessions: Map>, - currentTime: Instant -): Int { - var index = 0 - var bestIndex = -1 - - for ((_, sessions) in groupedSessions) { - val anyActive = sessions.any { isSessionActive(it, currentTime) } - val isFuture = sessions.firstOrNull()?.let { - try { Instant.parse(it.startTimeZulu).isAfter(currentTime) } catch (_: Exception) { false } - } ?: false - - if (anyActive) { - return index - } - if (isFuture && bestIndex == -1) { - bestIndex = index - } - - index += 1 + sessions.size // header + sessions count - } - - return if (bestIndex != -1) bestIndex else 0 -} +private fun dayLabel(day: String?) = when (day) { null -> "Alle"; "Wednesday" -> "Onsdag"; "Thursday" -> "Torsdag"; else -> day } +private fun formatLabel(format: String) = when { format.contains("presentation", true) -> "Foredrag"; format.contains("lightning", true) -> "Lynforedrag"; format.contains("workshop", true) -> "Workshop"; else -> format } +private fun languageLabel(lang: String) = if (lang.contains("no", true)) "🇳🇴 NO" else "🇬🇧 EN" +private fun formatTime(zulu: String): String = try { java.time.Instant.parse(zulu).atZone(java.time.ZoneId.of("Europe/Oslo")).toLocalTime().toString().take(5) } catch (_: Exception) { "--:--" } +private fun isActive(s: Session, now: Instant) = try { now >= Instant.parse(s.startTimeZulu) && now < Instant.parse(s.endTimeZulu) } catch (_: Exception) { false } +private fun isPast(s: Session, now: Instant) = try { now > Instant.parse(s.endTimeZulu) } catch (_: Exception) { false } +private fun progress(s: Session, now: Instant): Float = try { val a=Instant.parse(s.startTimeZulu); val b=Instant.parse(s.endTimeZulu); (Duration.between(a, now).toMillis().toFloat()/Duration.between(a,b).toMillis()).coerceIn(0f,1f) } catch (_: Exception) { 0f } +private fun remaining(s: Session, now: Instant): Long = try { Duration.between(now, Instant.parse(s.endTimeZulu)).toMinutes().coerceAtLeast(0) } catch (_: Exception) { 0 } +private fun findFirstActiveOrUpcomingIndex(grouped: Map>, now: Instant): Int { var index=0; var future=-1; for ((_, list) in grouped) { if (list.any { isActive(it,now) }) return index; if (future<0 && list.firstOrNull()?.let { try { Instant.parse(it.startTimeZulu)>now } catch (_:Exception){false} } == true) future=index; index += 1+list.size }; return future.coerceAtLeast(0) } From e6555b042974b1cdd3c68dd520d80d6e5b880264 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:02 +0200 Subject: [PATCH 07/36] tmp --- .../java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt new file mode 100644 index 0000000..1b67c6e --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt @@ -0,0 +1 @@ +// temporary \ No newline at end of file From 57a6549b004bb52c01e6aaf02baeb623777b731f Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:09 +0200 Subject: [PATCH 08/36] chore: remove temporary timeline viewmodel file --- .../java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt deleted file mode 100644 index 1b67c6e..0000000 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModelV2.kt +++ /dev/null @@ -1 +0,0 @@ -// temporary \ No newline at end of file From b3d0ebc45fbb7f4821201af1673e0551c64130c5 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:12 +0200 Subject: [PATCH 09/36] refactor: replace timeline viewmodel with year-aware implementation --- .../javazone/ui/timeline/TimelineViewModel.kt | 278 ------------------ 1 file changed, 278 deletions(-) delete mode 100644 app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt deleted file mode 100644 index abb9356..0000000 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt +++ /dev/null @@ -1,278 +0,0 @@ -package com.olavbg.javazone.ui.timeline - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.olavbg.javazone.data.repository.SessionRepository -import com.olavbg.javazone.data.repository.SettingsRepository -import com.olavbg.javazone.model.Session -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -class TimelineViewModel( - private val repository: SessionRepository, - private val settingsRepository: SettingsRepository, -) : ViewModel() { - - private val _selectedDay = MutableStateFlow(null) - val selectedDay = _selectedDay.asStateFlow() - - private val _onlyFavorites = MutableStateFlow(value = false) - val onlyFavorites = _onlyFavorites.asStateFlow() - - private val _selectedFormat = MutableStateFlow(null) - val selectedFormat = _selectedFormat.asStateFlow() - - private val _selectedLanguage = MutableStateFlow(null) - val selectedLanguage = _selectedLanguage.asStateFlow() - - private val _selectedRoom = MutableStateFlow(null) - val selectedRoom = _selectedRoom.asStateFlow() - - private val _filterSpeaker = MutableStateFlow(null) - val filterSpeaker = _filterSpeaker.asStateFlow() - - private val _searchQuery = MutableStateFlow("") - val searchQuery = _searchQuery.asStateFlow() - - private val _currentTime = MutableStateFlow(Instant.now()) - val currentTime: StateFlow = _currentTime - .combine(settingsRepository.simulatedTimeOffset) { time, offset -> - time.plusMillis(offset) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Instant.now()) - - val allSessions: StateFlow> = repository.getSessions() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - // Sessions filtered by selected day only (used for calculating dynamic filter options) - val daySessions: StateFlow> = combine(allSessions, _selectedDay) { list, day -> - if (day == null) list else list.filter { getDayFromZulu(it.startTimeZulu) == day } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val availableFormats: StateFlow> = daySessions.map { list -> - val result = mutableListOf() - if (list.any { isFormatMatch(it.format, "Presentation") }) result.add("Presentation") - if (list.any { isFormatMatch(it.format, "Lightning Talk") }) result.add("Lightning Talk") - if (list.any { isFormatMatch(it.format, "Workshop") }) result.add("Workshop") - result - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val availableLanguages: StateFlow> = daySessions.map { list -> - list.asSequence().mapNotNull { it.language }.distinct().sorted().toList() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val availableRooms: StateFlow> = daySessions.map { list -> - list.asSequence() - .map { it.room } - .filter { it.isNotBlank() } - .distinct() - .sortedWith(compareBy({ extractRoomNumber(it) }, { it })) - .toList() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val sessions: StateFlow> = combine( - allSessions, - _selectedDay, - _onlyFavorites, - _selectedFormat, - _selectedLanguage, - _filterSpeaker, - _selectedRoom, - _searchQuery - ) { args: Array -> - val list = args[0] as List - val day = args[1] as String? - val favoritesOnly = args[2] as Boolean - val format = args[3] as String? - val language = args[4] as String? - val speaker = args[5] as String? - val room = args[6] as String? - val query = args[7] as String - - var filtered = list - if (day != null && speaker == null) { // Don't filter by day if looking for a specific speaker's all talks - filtered = filtered.filter { getDayFromZulu(it.startTimeZulu) == day } - } - if (favoritesOnly) { - filtered = filtered.filter { it.isFavorite } - } - if (format != null) { - filtered = filtered.filter { isFormatMatch(it.format, format) } - } - if (language != null) { - filtered = filtered.filter { it.language?.equals(language, ignoreCase = true) == true } - } - if (speaker != null) { - filtered = filtered.filter { it.speakers.any { s -> s.name.equals(speaker, ignoreCase = true) } } - } - if (room != null) { - filtered = filtered.filter { it.room.equals(room, ignoreCase = true) } - } - if (query.isNotBlank()) { - val q = query.trim().lowercase() - filtered = filtered.filter { session -> - session.title.lowercase().contains(q) || - session.room.lowercase().contains(q) || - session.speakers.any { it.name.lowercase().contains(q) } || - session.abstract.lowercase().contains(q) - } - } - filtered - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val groupedSessions: StateFlow>> = sessions.map { list -> - list.sortedWith( - compareBy { it.startTimeZulu } - .thenBy { extractRoomNumber(it.room) } - .thenBy { it.room }, - ).groupBy { formatTime(it.startTimeZulu) } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) - - private val _isLoading = MutableStateFlow(value = true) - val isLoading: StateFlow = _isLoading.asStateFlow() - - private var hasAutoSelectedDay = false - private var hasScrolledForDay = false - - init { - viewModelScope.launch { - try { - repository.refreshSessions() - } finally { - _isLoading.value = false - } - } - // Auto-select current day matching effective currentTime (including simulated time settings) - viewModelScope.launch { - combine(allSessions, currentTime) { list, time -> list to time } - .collect { (list, time) -> - if (!hasAutoSelectedDay && list.isNotEmpty()) { - val currentDayName = try { - time.atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("EEEE", java.util.Locale.ENGLISH)) - } catch (_: Exception) { "" } - - val matchingDay = list.map { getDayFromZulu(it.startTimeZulu) } - .firstOrNull { it.equals(currentDayName, ignoreCase = true) } - - if (matchingDay != null) { - _selectedDay.value = matchingDay - hasAutoSelectedDay = true - } - } - } - } - // Update current time every minute - viewModelScope.launch { - while (true) { - _currentTime.value = Instant.now() - delay(Duration.ofMinutes(1).toMillis()) - } - } - } - - fun setDay(day: String?) { - _selectedDay.value = day - hasScrolledForDay = false - // Clear format/room selection if no longer available in the new day - if (_selectedFormat.value != null && !(availableFormats.value.contains(_selectedFormat.value))) { - _selectedFormat.value = null - } - if (_selectedRoom.value != null && !availableRooms.value.contains(_selectedRoom.value)) { - _selectedRoom.value = null - } - } - - fun shouldScrollToNow(): Boolean { - return !hasScrolledForDay - } - - fun markScrolledToNow() { - hasScrolledForDay = true - } - - fun setOnlyFavorites(only: Boolean) { - _onlyFavorites.value = only - } - - fun setFormat(format: String?) { - _selectedFormat.value = format - } - - fun setLanguage(language: String?) { - _selectedLanguage.value = language - } - - fun setRoom(room: String?) { - _selectedRoom.value = room - } - - fun setFilterSpeaker(speaker: String?) { - _filterSpeaker.value = speaker - if (speaker != null) { - _selectedDay.value = null // Show all days for the speaker - } - } - - fun setSearchQuery(query: String) { - _searchQuery.value = query - } - - fun toggleFavorite(session: Session) { - viewModelScope.launch { - repository.toggleFavorite(session.id, !session.isFavorite) - } - } - - private fun formatTime(zulu: String): String { - return try { - val instant = Instant.parse(zulu) - val dateTime = instant.atZone(ZoneId.of("Europe/Oslo")) - dateTime.format(DateTimeFormatter.ofPattern("HH:mm")) - } catch (e: Exception) { - "" - } - } - - private fun getDayFromZulu(zulu: String): String { - return try { - val instant = Instant.parse(zulu) - val dateTime = instant.atZone(ZoneId.of("Europe/Oslo")) - dateTime.format(DateTimeFormatter.ofPattern("EEEE", java.util.Locale.ENGLISH)) - } catch (e: Exception) { - "" - } - } - - private fun isSessionActive(session: Session, currentTime: Instant): Boolean { - return try { - val start = Instant.parse(session.startTimeZulu) - val end = Instant.parse(session.endTimeZulu) - (currentTime.isAfter(start) || currentTime == start) && currentTime.isBefore(end) - } catch (e: Exception) { - false - } - } - - private fun isFormatMatch(sessionFormat: String, targetFormat: String): Boolean { - val s = sessionFormat.lowercase().trim() - val target = targetFormat.lowercase().trim() - return when { - target.contains("lightning") -> s.contains("lightning") - target.contains("workshop") -> s.contains("workshop") - target.contains("presentation") -> s.contains("presentation") || s.contains("foredrag") - else -> s == target - } - } - - private fun extractRoomNumber(room: String): Int { - val digits = room.filter { it.isDigit() } - return digits.toIntOrNull() ?: Int.MAX_VALUE - } -} - - From ed49e85994fa848a5c5f71d9566602978dad549e Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:23 +0200 Subject: [PATCH 10/36] feat: add year-aware timeline state and historical programme loading --- .../javazone/ui/timeline/TimelineViewModel.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt new file mode 100644 index 0000000..86e5853 --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt @@ -0,0 +1,110 @@ +package com.olavbg.javazone.ui.timeline + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.olavbg.javazone.data.repository.SessionRepository +import com.olavbg.javazone.data.repository.SettingsRepository +import com.olavbg.javazone.model.Session +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class TimelineViewModel(private val repository: SessionRepository, private val settingsRepository: SettingsRepository) : ViewModel() { + companion object { const val CURRENT_YEAR = 2026 } + private val _selectedYear = MutableStateFlow(CURRENT_YEAR) + val selectedYear = _selectedYear.asStateFlow() + private val _selectedDay = MutableStateFlow(null) + val selectedDay = _selectedDay.asStateFlow() + private val _onlyFavorites = MutableStateFlow(false) + val onlyFavorites = _onlyFavorites.asStateFlow() + private val _selectedFormat = MutableStateFlow(null) + val selectedFormat = _selectedFormat.asStateFlow() + private val _selectedLanguage = MutableStateFlow(null) + val selectedLanguage = _selectedLanguage.asStateFlow() + private val _selectedRoom = MutableStateFlow(null) + val selectedRoom = _selectedRoom.asStateFlow() + private val _filterSpeaker = MutableStateFlow(null) + val filterSpeaker = _filterSpeaker.asStateFlow() + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + private val _yearSessions = MutableStateFlow>(emptyList()) + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + private var hasAutoSelectedDay = false + private var hasScrolledForContext = false + + private val _currentTime = MutableStateFlow(Instant.now()) + val currentTime: StateFlow = _currentTime.combine(settingsRepository.simulatedTimeOffset) { time, offset -> time.plusMillis(offset) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Instant.now()) + private val currentYearSessions = repository.getSessions().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val sourceSessions = combine(_selectedYear, currentYearSessions, _yearSessions) { year, current, historical -> if (year == CURRENT_YEAR) current else historical } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val daySessions = combine(sourceSessions, _selectedDay) { list, day -> if (day == null) list else list.filter { getDayFromZulu(it.startTimeZulu) == day } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val availableFormats = daySessions.map { list -> list.map { it.format }.filter { it.isNotBlank() }.distinct().filter { it.contains("presentation", true) || it.contains("lightning", true) || it.contains("workshop", true) }.sorted() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val availableLanguages = daySessions.map { list -> list.mapNotNull { it.language }.distinct().sorted() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val availableRooms = daySessions.map { list -> list.map { it.room }.filter { it.isNotBlank() }.distinct().sortedWith(compareBy({ extractRoomNumber(it) }, { it })) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val sessions = combine(sourceSessions, _selectedDay, _onlyFavorites, _selectedFormat, _selectedLanguage, _filterSpeaker, _selectedRoom, _searchQuery) { args -> + val list = args[0] as List; val day = args[1] as String?; val favorites = args[2] as Boolean + val format = args[3] as String?; val language = args[4] as String?; val speaker = args[5] as String?; val room = args[6] as String?; val query = args[7] as String + var filtered = list + if (day != null && speaker == null) filtered = filtered.filter { getDayFromZulu(it.startTimeZulu) == day } + if (favorites) filtered = filtered.filter { it.isFavorite } + if (format != null) filtered = filtered.filter { isFormatMatch(it.format, format) } + if (language != null) filtered = filtered.filter { it.language?.equals(language, true) == true } + if (speaker != null) filtered = filtered.filter { it.speakers.any { s -> s.name.equals(speaker, true) } } + if (room != null) filtered = filtered.filter { it.room.equals(room, true) } + if (query.isNotBlank()) { val q = query.trim().lowercase(); filtered = filtered.filter { it.title.lowercase().contains(q) || it.room.lowercase().contains(q) || it.abstract.lowercase().contains(q) || it.speakers.any { s -> s.name.lowercase().contains(q) } } } + filtered + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val groupedSessions = sessions.map { list -> list.sortedWith(compareBy { it.startTimeZulu }.thenBy { extractRoomNumber(it.room) }.thenBy { it.room }).groupBy { formatTime(it.startTimeZulu) } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + init { + viewModelScope.launch { try { repository.refreshSessions() } finally { _isLoading.value = false } } + viewModelScope.launch { + combine(currentYearSessions, currentTime) { list, time -> list to time }.collect { (list, time) -> + if (!hasAutoSelectedDay && list.isNotEmpty()) { + val today = time.atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("EEEE", Locale.ENGLISH)) + list.map { getDayFromZulu(it.startTimeZulu) }.firstOrNull { it.equals(today, true) }?.let { _selectedDay.value = it } + hasAutoSelectedDay = true + } + } + } + viewModelScope.launch { while (true) { _currentTime.value = Instant.now(); delay(Duration.ofMinutes(1).toMillis()) } } + } + + fun setYear(year: Int) { + if (_selectedYear.value == year) return + _selectedYear.value = year; _selectedDay.value = null; _onlyFavorites.value = false + _selectedFormat.value = null; _selectedLanguage.value = null; _selectedRoom.value = null; _filterSpeaker.value = null; _searchQuery.value = ""; hasScrolledForContext = false + if (year != CURRENT_YEAR) loadHistoricalYear(year) + } + private fun loadHistoricalYear(year: Int) { viewModelScope.launch { _isLoading.value = true; _yearSessions.value = repository.getArchiveSessions(year); _isLoading.value = false } } + fun setDay(day: String?) { _selectedDay.value = day; hasScrolledForContext = false; if (_selectedFormat.value != null && !availableFormats.value.contains(_selectedFormat.value)) _selectedFormat.value = null; if (_selectedRoom.value != null && !availableRooms.value.contains(_selectedRoom.value)) _selectedRoom.value = null } + fun setOnlyFavorites(value: Boolean) { _onlyFavorites.value = value } + fun setFormat(value: String?) { _selectedFormat.value = value } + fun setLanguage(value: String?) { _selectedLanguage.value = value } + fun setRoom(value: String?) { _selectedRoom.value = value } + fun setFilterSpeaker(value: String?) { _filterSpeaker.value = value; if (value != null) _selectedDay.value = null } + fun setSearchQuery(value: String) { _searchQuery.value = value } + fun clearFilters() { _onlyFavorites.value = false; _selectedFormat.value = null; _selectedLanguage.value = null; _selectedRoom.value = null; _searchQuery.value = "" } + fun activeFilterCount() = listOf(_onlyFavorites.value, _selectedFormat.value != null, _selectedLanguage.value != null, _selectedRoom.value != null, _searchQuery.value.isNotBlank()).count { it } + fun shouldScrollToNow() = !hasScrolledForContext + fun markScrolledToNow() { hasScrolledForContext = true } + fun toggleFavorite(session: Session) { if (_selectedYear.value == CURRENT_YEAR) viewModelScope.launch { repository.toggleFavorite(session.id, !session.isFavorite) } } + + private fun formatTime(zulu: String) = try { Instant.parse(zulu).atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("HH:mm")) } catch (_: Exception) { "" } + private fun getDayFromZulu(zulu: String) = try { Instant.parse(zulu).atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("EEEE", Locale.ENGLISH)) } catch (_: Exception) { "" } + private fun isFormatMatch(value: String, target: String) = when { target.contains("lightning", true) -> value.contains("lightning", true); target.contains("workshop", true) -> value.contains("workshop", true); target.contains("presentation", true) -> value.contains("presentation", true) || value.contains("foredrag", true); else -> value.equals(target, true) } + private fun extractRoomNumber(room: String) = room.filter { it.isDigit() }.toIntOrNull() ?: Int.MAX_VALUE +} From ead15a7a632f9d9542fc8114864f2d35027adf04 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:29 +0200 Subject: [PATCH 11/36] feat: add expressive year picker sheet --- .../javazone/ui/components/YearPicker.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt b/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt new file mode 100644 index 0000000..bb010d3 --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt @@ -0,0 +1,53 @@ +package com.olavbg.javazone.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun YearPickerSheet( + selectedYear: Int, + years: List, + onYearSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("Velg JavaZone-år", style = androidx.compose.material3.MaterialTheme.typography.headlineSmall) + Text("Bytt program uten å forlate tidslinjen.", style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant) + LazyColumn( + contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(years, key = { it }) { year -> + FilterChip( + selected = year == selectedYear, + onClick = { onYearSelected(year) }, + label = { Text(year.toString()) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} From 45c4a0e383eb21d82409da43397382d28386b2c3 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:40 +0200 Subject: [PATCH 12/36] refactor: integrate year picker into primary navigation --- .../com/olavbg/javazone/ui/JavaZoneApp.kt | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt deleted file mode 100644 index 5c8ceca..0000000 --- a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.olavbg.javazone.ui - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.scene.SinglePaneSceneStrategy -import androidx.navigation3.ui.NavDisplay -import com.olavbg.javazone.data.repository.SessionRepository -import com.olavbg.javazone.data.repository.SettingsRepository -import com.olavbg.javazone.notifications.ReminderManager -import com.olavbg.javazone.ui.archive.ArchiveScreen -import com.olavbg.javazone.ui.archive.ArchiveViewModelFactory -import com.olavbg.javazone.ui.detail.SessionDetailScreen -import com.olavbg.javazone.ui.navigation.NavDestination -import com.olavbg.javazone.ui.settings.SettingsScreen -import com.olavbg.javazone.ui.settings.SettingsViewModelFactory -import com.olavbg.javazone.ui.timeline.TimelineScreen -import com.olavbg.javazone.ui.timeline.TimelineViewModel -import com.olavbg.javazone.ui.timeline.TimelineViewModelFactory - -@Composable -fun JavaZoneApp( - repository: SessionRepository, - settingsRepository: SettingsRepository, - reminderManager: ReminderManager, - initialSessionId: String? = null, -) { - val timelineViewModel: TimelineViewModel = viewModel( - factory = TimelineViewModelFactory(repository, settingsRepository) - ) - - val backStack = if (initialSessionId != null) { - rememberNavBackStack( - NavDestination.Timeline, - NavDestination.SessionDetail(initialSessionId) - ) - } else { - rememberNavBackStack(NavDestination.Timeline) - } - - // The schedule is the primary product surface. Historical programmes will be - // exposed through the year selector rather than a persistent bottom navigation item. - Scaffold { innerPadding -> - NavDisplay( - backStack = backStack, - sceneStrategies = listOf(SinglePaneSceneStrategy()), - modifier = androidx.compose.ui.Modifier.fillMaxSize(), - entryProvider = { key -> - when (key) { - NavDestination.Timeline -> NavEntry(key = key) { - TimelineScreen( - viewModel = timelineViewModel, - onSessionClick = { id -> - backStack.add(NavDestination.SessionDetail(id)) - }, - onSettingsClick = { - backStack.add(NavDestination.Settings) - }, - contentPadding = innerPadding - ) - } - - NavDestination.Archive -> NavEntry(key = key) { - ArchiveScreen( - viewModel = viewModel(factory = ArchiveViewModelFactory(repository)), - contentPadding = innerPadding - ) - } - - is NavDestination.SessionDetail -> NavEntry(key = key) { - SessionDetailScreen( - sessionId = key.sessionId, - repository = repository, - settingsRepository = settingsRepository, - onBackClick = { backStack.removeAt(backStack.size - 1) }, - onSpeakerClick = { name -> - timelineViewModel.setFilterSpeaker(name) - backStack.removeAt(backStack.size - 1) - }, - contentPadding = innerPadding - ) - } - - NavDestination.Settings -> NavEntry(key = key) { - SettingsScreen( - viewModel = viewModel( - factory = SettingsViewModelFactory( - settingsRepository, - repository, - reminderManager - ) - ), - onBackClick = { backStack.removeAt(backStack.size - 1) }, - contentPadding = innerPadding - ) - } - - else -> NavEntry(key) { } - } - } - ) - } -} From cc2113a6db8b6f4164720ed12ec436ae56365aac Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:46 +0200 Subject: [PATCH 13/36] feat: replace archive navigation with year picker --- .../com/olavbg/javazone/ui/JavaZoneApp.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt new file mode 100644 index 0000000..b77fa76 --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt @@ -0,0 +1,90 @@ +package com.olavbg.javazone.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.* +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.SinglePaneSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.olavbg.javazone.data.repository.SessionRepository +import com.olavbg.javazone.data.repository.SettingsRepository +import com.olavbg.javazone.notifications.ReminderManager +import com.olavbg.javazone.ui.components.YearPickerSheet +import com.olavbg.javazone.ui.detail.SessionDetailScreen +import com.olavbg.javazone.ui.navigation.NavDestination +import com.olavbg.javazone.ui.settings.SettingsScreen +import com.olavbg.javazone.ui.settings.SettingsViewModelFactory +import com.olavbg.javazone.ui.timeline.TimelineScreen +import com.olavbg.javazone.ui.timeline.TimelineViewModel +import com.olavbg.javazone.ui.timeline.TimelineViewModelFactory + +@Composable +fun JavaZoneApp( + repository: SessionRepository, + settingsRepository: SettingsRepository, + reminderManager: ReminderManager, + initialSessionId: String? = null, +) { + val timelineViewModel: TimelineViewModel = viewModel(factory = TimelineViewModelFactory(repository, settingsRepository)) + var yearPickerVisible by rememberSaveable { mutableStateOf(false) } + val years = remember { (2014..TimelineViewModel.CURRENT_YEAR).toList().reversed() } + + val backStack = if (initialSessionId != null) { + rememberNavBackStack(NavDestination.Timeline, NavDestination.SessionDetail(initialSessionId)) + } else { + rememberNavBackStack(NavDestination.Timeline) + } + + Scaffold { innerPadding -> + NavDisplay( + backStack = backStack, + sceneStrategies = listOf(SinglePaneSceneStrategy()), + modifier = androidx.compose.ui.Modifier.fillMaxSize(), + entryProvider = { key -> + when (key) { + NavDestination.Timeline -> NavEntry(key = key) { + TimelineScreen( + viewModel = timelineViewModel, + onSessionClick = { id -> backStack.add(NavDestination.SessionDetail(id)) }, + onSettingsClick = { backStack.add(NavDestination.Settings) }, + onYearClick = { yearPickerVisible = true }, + contentPadding = innerPadding + ) + } + is NavDestination.SessionDetail -> NavEntry(key = key) { + SessionDetailScreen( + sessionId = key.sessionId, + repository = repository, + settingsRepository = settingsRepository, + onBackClick = { backStack.removeAt(backStack.size - 1) }, + onSpeakerClick = { name -> + timelineViewModel.setFilterSpeaker(name) + backStack.removeAt(backStack.size - 1) + }, + contentPadding = innerPadding + ) + } + NavDestination.Settings -> NavEntry(key = key) { + SettingsScreen( + viewModel = viewModel(factory = SettingsViewModelFactory(settingsRepository, repository, reminderManager)), + onBackClick = { backStack.removeAt(backStack.size - 1) }, + contentPadding = innerPadding + ) + } + else -> NavEntry(key) { } + } + } + ) + } + + if (yearPickerVisible) { + YearPickerSheet( + selectedYear = timelineViewModel.selectedYear.collectAsState().value, + years = years, + onYearSelected = { year -> timelineViewModel.setYear(year); yearPickerVisible = false }, + onDismiss = { yearPickerVisible = false }, + ) + } +} From 1ed2ed4f8d7bc1607f03090e94cddbdf47584f29 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:54 +0200 Subject: [PATCH 14/36] refactor: remove archive navigation destination --- .../javazone/ui/navigation/NavDestinations.kt | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt deleted file mode 100644 index 4f57138..0000000 --- a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.olavbg.javazone.ui.navigation - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -@Serializable -sealed interface NavDestination : NavKey { - @Serializable - data object Timeline : NavDestination - - @Serializable - data class SessionDetail(val sessionId: String) : NavDestination - - @Serializable - data object Archive : NavDestination - - @Serializable - data object Settings : NavDestination -} From 2270573f68d8aae789afd745708cece4ca565345 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:00:57 +0200 Subject: [PATCH 15/36] refactor: remove archive navigation destination --- .../javazone/ui/navigation/NavDestinations.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt diff --git a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt new file mode 100644 index 0000000..fba1103 --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt @@ -0,0 +1,16 @@ +package com.olavbg.javazone.ui.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface NavDestination : NavKey { + @Serializable + data object Timeline : NavDestination + + @Serializable + data class SessionDetail(val sessionId: String) : NavDestination + + @Serializable + data object Settings : NavDestination +} From 04b0dbce8bd6716650099a8c2e5a0797d9d0558e Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:03:14 +0200 Subject: [PATCH 16/36] fix: derive timeline days from selected programme --- .../olavbg/javazone/ui/timeline/TimelineViewModel.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt index 86e5853..216657a 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt @@ -44,6 +44,15 @@ class TimelineViewModel(private val repository: SessionRepository, private val s private val currentYearSessions = repository.getSessions().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val sourceSessions = combine(_selectedYear, currentYearSessions, _yearSessions) { year, current, historical -> if (year == CURRENT_YEAR) current else historical } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + /** Days are derived from the selected programme instead of being hard-coded to the current conference. */ + val availableDays = sourceSessions.map { list -> + list.map { getDayFromZulu(it.startTimeZulu) } + .filter { it.isNotBlank() } + .distinct() + .sortedBy { daySortIndex(it) } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val daySessions = combine(sourceSessions, _selectedDay) { list, day -> if (day == null) list else list.filter { getDayFromZulu(it.startTimeZulu) == day } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val availableFormats = daySessions.map { list -> list.map { it.format }.filter { it.isNotBlank() }.distinct().filter { it.contains("presentation", true) || it.contains("lightning", true) || it.contains("workshop", true) }.sorted() } @@ -105,6 +114,7 @@ class TimelineViewModel(private val repository: SessionRepository, private val s private fun formatTime(zulu: String) = try { Instant.parse(zulu).atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("HH:mm")) } catch (_: Exception) { "" } private fun getDayFromZulu(zulu: String) = try { Instant.parse(zulu).atZone(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ofPattern("EEEE", Locale.ENGLISH)) } catch (_: Exception) { "" } + private fun daySortIndex(day: String) = listOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday").indexOf(day).let { if (it < 0) Int.MAX_VALUE else it } private fun isFormatMatch(value: String, target: String) = when { target.contains("lightning", true) -> value.contains("lightning", true); target.contains("workshop", true) -> value.contains("workshop", true); target.contains("presentation", true) -> value.contains("presentation", true) || value.contains("foredrag", true); else -> value.equals(target, true) } private fun extractRoomNumber(room: String) = room.filter { it.isDigit() }.toIntOrNull() ?: Int.MAX_VALUE } From 9c2e16f93814dbac061b73d9e220dc3c2f8e8282 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:03:31 +0200 Subject: [PATCH 17/36] fix: support historical day tabs and disable archived favorites --- .../javazone/ui/timeline/TimelineScreen.kt | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt index a908d9e..1f5a6a4 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt @@ -38,8 +38,6 @@ import com.olavbg.javazone.ui.components.RoomTag import java.time.Duration import java.time.Instant -private val DayOptions = listOf(null, "Wednesday", "Thursday") - @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun TimelineScreen( @@ -61,6 +59,7 @@ fun TimelineScreen( val selectedRoom by viewModel.selectedRoom.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val currentTime by viewModel.currentTime.collectAsState() + val availableDays by viewModel.availableDays.collectAsState() val availableFormats by viewModel.availableFormats.collectAsState() val availableLanguages by viewModel.availableLanguages.collectAsState() val availableRooms by viewModel.availableRooms.collectAsState() @@ -68,6 +67,7 @@ fun TimelineScreen( var searchVisible by rememberSaveable { mutableStateOf(false) } var filtersVisible by rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() + val favoritesEnabled = selectedYear == TimelineViewModel.CURRENT_YEAR LaunchedEffect(groupedSessions, selectedDay, selectedYear) { if (groupedSessions.isNotEmpty() && viewModel.shouldScrollToNow()) { @@ -94,7 +94,10 @@ fun TimelineScreen( IconButton(onClick = { searchVisible = !searchVisible; if (!searchVisible) viewModel.setSearchQuery("") }) { Icon(if (searchVisible) Icons.Default.Clear else Icons.Default.Search, "Søk") } - IconButton(onClick = { viewModel.setOnlyFavorites(!onlyFavorites) }) { + IconButton( + onClick = { if (favoritesEnabled) viewModel.setOnlyFavorites(!onlyFavorites) }, + enabled = favoritesEnabled + ) { Icon(if (onlyFavorites) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritter", tint = if (onlyFavorites) MaterialTheme.colorScheme.tertiary else LocalContentColor.current) } IconButton(onClick = onSettingsClick) { Icon(Icons.Rounded.Settings, "Innstillinger") } @@ -117,18 +120,28 @@ fun TimelineScreen( } Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { - SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { - DayOptions.forEachIndexed { index, day -> + if (availableDays.isNotEmpty()) { + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { SegmentedButton( - selected = selectedDay == day, - onClick = { viewModel.setDay(day) }, - shape = SegmentedButtonDefaults.itemShape(index, DayOptions.size) - ) { Text(dayLabel(day), fontSize = 12.sp, maxLines = 1) } + selected = selectedDay == null, + onClick = { viewModel.setDay(null) }, + shape = SegmentedButtonDefaults.itemShape(0, availableDays.size + 1) + ) { Text("Alle", fontSize = 12.sp, maxLines = 1) } + availableDays.forEachIndexed { index, day -> + SegmentedButton( + selected = selectedDay == day, + onClick = { viewModel.setDay(day) }, + shape = SegmentedButtonDefaults.itemShape(index + 1, availableDays.size + 1) + ) { Text(dayLabel(day), fontSize = 12.sp, maxLines = 1) } + } } + } else { + Spacer(Modifier.weight(1f)) } Spacer(Modifier.width(8.dp)) IconButton(onClick = { filtersVisible = !filtersVisible }) { - BadgedBox(badge = { if (viewModel.activeFilterCount() > 0) Badge { Text(viewModel.activeFilterCount().toString()) } }) { + val count = viewModel.activeFilterCount() + BadgedBox(badge = { if (count > 0) Badge { Text(count.toString()) } }) { Icon(Icons.Default.FilterList, "Filtre") } } @@ -151,7 +164,7 @@ fun TimelineScreen( } else if (sessions.isEmpty()) { EmptyStateView(searchQuery.isNotBlank() || viewModel.activeFilterCount() > 0, viewModel::clearFilters) } else { - TimelineList(groupedSessions, currentTime, listState, onSessionClick, viewModel::toggleFavorite, contentPadding) + TimelineList(groupedSessions, currentTime, listState, onSessionClick, viewModel::toggleFavorite, favoritesEnabled, contentPadding) } if (filterSpeaker != null) { @@ -173,6 +186,7 @@ private fun TimelineList( listState: androidx.compose.foundation.lazy.LazyListState, onSessionClick: (String) -> Unit, onFavoriteClick: (Session) -> Unit, + favoritesEnabled: Boolean, contentPadding: PaddingValues ) { LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 32.dp + contentPadding.calculateBottomPadding()), modifier = Modifier.fillMaxSize()) { @@ -194,14 +208,14 @@ private fun TimelineList( items(sessions, key = { it.id }) { session -> val active = isActive(session, currentTime) val past = isPast(session, currentTime) - SessionCard(session, active, past, currentTime, onFavoriteClick, { onSessionClick(session.id) }) + SessionCard(session, active, past, currentTime, onFavoriteClick, favoritesEnabled, { onSessionClick(session.id) }) } } } } @Composable -private fun SessionCard(session: Session, active: Boolean, past: Boolean, currentTime: Instant, onFavoriteClick: (Session) -> Unit, onClick: () -> Unit) { +private fun SessionCard(session: Session, active: Boolean, past: Boolean, currentTime: Instant, onFavoriteClick: (Session) -> Unit, favoritesEnabled: Boolean, onClick: () -> Unit) { val alpha by androidx.compose.animation.core.animateFloatAsState(if (past) .48f else 1f, label = "pastAlpha") Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 5.dp).alpha(alpha), shape = RoundedCornerShape(if (active) 24.dp else 18.dp), colors = CardDefaults.cardColors(containerColor = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow), border = if (active) BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = .7f)) else null) { Column(Modifier.padding(14.dp)) { @@ -212,7 +226,7 @@ private fun SessionCard(session: Session, active: Boolean, past: Boolean, curren Spacer(Modifier.width(6.dp)) FormatBadge(session.format) Spacer(Modifier.weight(1f)) - IconButton(onClick = { onFavoriteClick(session) }, modifier = Modifier.size(32.dp)) { Icon(if (session.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritt", tint = if (session.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current, modifier = Modifier.size(18.dp)) } + IconButton(onClick = { if (favoritesEnabled) onFavoriteClick(session) }, enabled = favoritesEnabled, modifier = Modifier.size(32.dp)) { Icon(if (session.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritt", tint = if (session.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current, modifier = Modifier.size(18.dp)) } } Spacer(Modifier.height(8.dp)) Text(session.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) @@ -243,7 +257,16 @@ private fun EmptyStateView(active: Boolean, clear: () -> Unit) { } } -private fun dayLabel(day: String?) = when (day) { null -> "Alle"; "Wednesday" -> "Onsdag"; "Thursday" -> "Torsdag"; else -> day } +private fun dayLabel(day: String) = when (day) { + "Monday" -> "Man" + "Tuesday" -> "Tir" + "Wednesday" -> "Ons" + "Thursday" -> "Tor" + "Friday" -> "Fre" + "Saturday" -> "Lør" + "Sunday" -> "Søn" + else -> day.take(3) +} private fun formatLabel(format: String) = when { format.contains("presentation", true) -> "Foredrag"; format.contains("lightning", true) -> "Lynforedrag"; format.contains("workshop", true) -> "Workshop"; else -> format } private fun languageLabel(lang: String) = if (lang.contains("no", true)) "🇳🇴 NO" else "🇬🇧 EN" private fun formatTime(zulu: String): String = try { java.time.Instant.parse(zulu).atZone(java.time.ZoneId.of("Europe/Oslo")).toLocalTime().toString().take(5) } catch (_: Exception) { "--:--" } From 878b303a34ec8b37c9d563f1df5b8556331d1668 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:03:38 +0200 Subject: [PATCH 18/36] fix: preserve programme year when opening session details --- .../com/olavbg/javazone/ui/navigation/NavDestinations.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt index fba1103..452fa10 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt @@ -9,7 +9,10 @@ sealed interface NavDestination : NavKey { data object Timeline : NavDestination @Serializable - data class SessionDetail(val sessionId: String) : NavDestination + data class SessionDetail( + val sessionId: String, + val year: Int = 2026, + ) : NavDestination @Serializable data object Settings : NavDestination From b904f4ba9dbe783b5fc542120112a196335d66a7 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:03:44 +0200 Subject: [PATCH 19/36] fix: route session details with selected programme year --- app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt index b77fa76..fbafbb9 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt @@ -47,7 +47,7 @@ fun JavaZoneApp( NavDestination.Timeline -> NavEntry(key = key) { TimelineScreen( viewModel = timelineViewModel, - onSessionClick = { id -> backStack.add(NavDestination.SessionDetail(id)) }, + onSessionClick = { id -> backStack.add(NavDestination.SessionDetail(id, timelineViewModel.selectedYear.value)) }, onSettingsClick = { backStack.add(NavDestination.Settings) }, onYearClick = { yearPickerVisible = true }, contentPadding = innerPadding @@ -56,6 +56,7 @@ fun JavaZoneApp( is NavDestination.SessionDetail -> NavEntry(key = key) { SessionDetailScreen( sessionId = key.sessionId, + year = key.year, repository = repository, settingsRepository = settingsRepository, onBackClick = { backStack.removeAt(backStack.size - 1) }, From 598a8ca92535516f4abf4e37c52b65f1b6024dc5 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:04:04 +0200 Subject: [PATCH 20/36] feat: redesign session details and support historical programmes --- .../javazone/ui/detail/SessionDetailScreen.kt | 578 ++++++------------ 1 file changed, 174 insertions(+), 404 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index 5d0b29f..3a7665b 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -1,7 +1,7 @@ package com.olavbg.javazone.ui.detail +import android.content.Intent import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -11,39 +11,35 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri -import android.content.Intent import com.olavbg.javazone.data.repository.SessionRepository import com.olavbg.javazone.data.repository.SettingsRepository +import com.olavbg.javazone.model.Session import com.olavbg.javazone.model.Speaker import com.olavbg.javazone.ui.components.FormatBadge import com.olavbg.javazone.ui.components.resolveVideoUrl -import com.olavbg.javazone.ui.theme.* -import com.olavbg.javazone.util.* -import kotlinx.coroutines.flow.map +import com.olavbg.javazone.ui.theme.JavaGreen +import com.olavbg.javazone.ui.theme.WorkshopPurple +import com.olavbg.javazone.ui.theme.JavaBlue +import com.olavbg.javazone.util.calculateSessionDurationMinutes +import com.olavbg.javazone.util.formatDay +import com.olavbg.javazone.util.formatTime +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionDetailScreen( sessionId: String, + year: Int, repository: SessionRepository, settingsRepository: SettingsRepository, onBackClick: () -> Unit, @@ -52,435 +48,209 @@ fun SessionDetailScreen( contentPadding: PaddingValues = PaddingValues(), ) { val scope = rememberCoroutineScope() - val sessionFlow = remember(sessionId) { - repository.getSessions().map { it.find { s -> s.id == sessionId } } - } - val session by sessionFlow.collectAsState(initial = null) + val context = LocalContext.current + val isCurrentYear = year == 2026 + var session by remember(sessionId, year) { mutableStateOf(null) } + var loading by remember(sessionId, year) { mutableStateOf(true) } + var simulatedTime by remember { mutableStateOf(Instant.now()) } val offset by settingsRepository.simulatedTimeOffset.collectAsState(initial = 0L) - var simulatedTime by remember { mutableStateOf(Instant.now().plusMillis(offset)) } + LaunchedEffect(offset) { simulatedTime = Instant.now().plusMillis(offset) } - // Update time when offset changes or when screen resumes - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, offset) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - simulatedTime = Instant.now().plusMillis(offset) - } + LaunchedEffect(sessionId, year) { + loading = true + session = if (isCurrentYear) { + repository.getSessions().first().find { it.id == sessionId } + } else { + repository.getArchiveSessions(year).find { it.id == sessionId } } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(offset) { - simulatedTime = Instant.now().plusMillis(offset) + loading = false } - var isRefreshing by remember { mutableStateOf(false) } - val pullToRefreshState = rememberPullToRefreshState() - Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { - Text( - text = "Foredrag", - fontWeight = FontWeight.Black, - style = MaterialTheme.typography.titleLarge, - ) + Column { + Text("Foredrag", fontWeight = FontWeight.Black) + Text("JavaZone $year", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } }, navigationIcon = { - IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tilbake") - } + IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Tilbake") } }, actions = { - session?.let { s -> - IconButton( - onClick = { - scope.launch { - repository.toggleFavorite(s.id, !s.isFavorite) - } - } - ) { + val current = session + if (current != null && isCurrentYear) { + IconButton(onClick = { scope.launch { repository.toggleFavorite(current.id, !current.isFavorite) } }) { Icon( - imageVector = if (s.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - contentDescription = null, - tint = if (s.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current + if (current.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + "Favoritt", + tint = if (current.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current ) } } } ) - }, - modifier = modifier + } ) { padding -> - session?.let { s -> - val startTime = remember(s.startTimeZulu) { Instant.parse(s.startTimeZulu) } - val endTime = remember(s.endTimeZulu) { Instant.parse(s.endTimeZulu) } - - val isFinished = endTime.isBefore(simulatedTime) - val isLive = !isFinished && simulatedTime.isAfter(startTime) - val minutesUntilStart = Duration.between(simulatedTime, startTime).toMinutes() - val startsSoon = !isFinished && (minutesUntilStart in 0..60) - - PullToRefreshBox( - state = pullToRefreshState, - isRefreshing = isRefreshing, - onRefresh = { - scope.launch { - isRefreshing = true - repository.refreshSessions() - isRefreshing = false - } + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + session == null -> EmptyDetail(onBackClick, padding, year) + else -> DetailContent( + session = session!!, + simulatedTime = simulatedTime, + isCurrentYear = isCurrentYear, + onSpeakerClick = onSpeakerClick, + onVideoClick = { + val url = session?.videoUrl?.let(::resolveVideoUrl) ?: return@DetailContent + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } }, - modifier = Modifier - .fillMaxSize() - .padding(top = padding.calculateTopPadding()) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - if (isFinished) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Info, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Dette foredraget er allerede avsluttet", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer - ) - - if (s.videoUrl != null) { - val context = LocalContext.current - Surface( - onClick = { - try { - val videoUrl = resolveVideoUrl(s.videoUrl) - val intent = Intent(Intent.ACTION_VIEW, videoUrl.toUri()).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } catch (_: Exception) {} - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), - modifier = Modifier.padding(top = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Icon( - Icons.Default.PlayCircle, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Se videoopptak", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - } - } - } - } - } - } - } else if (isLive) { - val minutesRemaining = Duration.between(simulatedTime, endTime).toMinutes().coerceAtLeast(0) - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.PlayCircle, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = "Går nå i ${s.room} – $minutesRemaining min igjen", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } else if (startsSoon) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Timer, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = "Starter om $minutesUntilStart minutter i ${s.room}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - } + modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding()), + bottomPadding = padding.calculateBottomPadding() + contentPadding.calculateBottomPadding() + ) + } + } +} - // Header Area - Box( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .padding(vertical = 24.dp, horizontal = 20.dp) - ) { - Column { - Text( - text = s.title, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Black, - lineHeight = 32.sp - ) - - Spacer(modifier = Modifier.height(16.dp)) +@Composable +private fun DetailContent( + session: Session, + simulatedTime: Instant, + isCurrentYear: Boolean, + onSpeakerClick: (String) -> Unit, + onVideoClick: () -> Unit, + modifier: Modifier, + bottomPadding: Dp, +) { + val start = runCatching { Instant.parse(session.startTimeZulu) }.getOrNull() + val end = runCatching { Instant.parse(session.endTimeZulu) }.getOrNull() + val live = start != null && end != null && simulatedTime >= start && simulatedTime < end + val finished = end != null && simulatedTime >= end + val upcoming = start != null && simulatedTime < start + val minutesToStart = start?.let { Duration.between(simulatedTime, it).toMinutes().coerceAtLeast(0) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - FormatBadge(format = s.format) - if (s.language != null) { - Spacer(modifier = Modifier.width(8.dp)) - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - ) { - Text( - text = if (s.language.contains("no", ignoreCase = true)) "🇳🇴 Norsk" else "🇬🇧 Engelsk", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) - ) - } - } - } - } - } + Column( + modifier = modifier.verticalScroll(rememberScrollState()).padding(bottom = bottomPadding + 32.dp) + ) { + if (live) StatusBanner("GÅR NÅ", "${session.room} · ${Duration.between(simulatedTime, end).toMinutes().coerceAtLeast(0)} min igjen", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) + else if (upcoming && minutesToStart != null && minutesToStart <= 60) StatusBanner("STARTER SNART", "Om $minutesToStart min · ${session.room}", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer) + else if (finished && session.videoUrl != null) StatusBanner("AVSLUTTET", "Opptak er tilgjengelig", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer, onVideoClick) - Column(modifier = Modifier.padding(20.dp)) { - // Time and Room Card - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Schedule, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "${formatDay(s.startTimeZulu)}, ${formatTime(s.startTimeZulu)} – ${formatTime(s.endTimeZulu)}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.LocationOn, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.secondary) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = s.room, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - val duration = calculateSessionDurationMinutes(s) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - ) { - Text( - text = "$duration min", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) - ) - } - } - } + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 24.dp)) { + Text("JavaZone $sessionYearLabel", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(10.dp)) + Text(session.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FormatBadge(session.format) + session.language?.let { AssistChip(onClick = {}, enabled = false, label = { Text(if (it.contains("no", true)) "🇳🇴 Norsk" else "🇬🇧 English") }) } + } + } + } + + Column(Modifier.padding(20.dp)) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = .6f)) + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + InfoRow(Icons.Default.Schedule, "Tid", "${formatDay(session.startTimeZulu)}, ${formatTime(session.startTimeZulu)}–${formatTime(session.endTimeZulu)}") + InfoRow(Icons.Default.LocationOn, "Rom", session.room) + InfoRow(Icons.Default.Timer, "Varighet", "${calculateSessionDurationMinutes(session)} min") + } + } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.height(28.dp)) + Text("Om foredraget", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(10.dp)) + Text(session.abstract.ifBlank { "Ingen beskrivelse er tilgjengelig." }, style = MaterialTheme.typography.bodyLarge, lineHeight = 26.sp) - Text( - text = "Om foredraget", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = s.abstract, - style = MaterialTheme.typography.bodyLarge, - lineHeight = 26.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f) - ) + if (session.speakers.isNotEmpty()) { + Spacer(Modifier.height(30.dp)) + Text(if (session.speakers.size == 1) "Foredragsholder" else "Foredragsholdere", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(12.dp)) + session.speakers.forEach { speaker -> + SpeakerCard(speaker, onClick = { onSpeakerClick(speaker.name) }) + Spacer(Modifier.height(10.dp)) + } + } - if (s.speakers.isNotEmpty()) { - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = if (s.speakers.size > 1) "Foredragsholdere" else "Foredragsholder", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(16.dp)) - - s.speakers.forEach { speaker -> - SpeakerItem( - speaker = speaker, - onClick = { onSpeakerClick(speaker.name) } - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } - - Spacer(modifier = Modifier.height(48.dp + contentPadding.calculateBottomPadding())) - } + if (finished && session.videoUrl != null) { + Spacer(Modifier.height(18.dp)) + Button(onClick = onVideoClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(18.dp)) { + Icon(Icons.Default.PlayCircle, null) + Spacer(Modifier.width(8.dp)) + Text("Se videoopptak") } } - } ?: Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } +private const val sessionYearLabel = "Program" + @Composable -fun SpeakerItem( - speaker: Speaker, - onClick: () -> Unit -) { - val context = LocalContext.current - Surface( - onClick = onClick, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(20.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Placeholder for Avatar - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - modifier = Modifier.size(56.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = speaker.name.firstOrNull()?.toString() ?: "?", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - } - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = speaker.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - if (speaker.twitter != null) { - TextButton( - onClick = { - try { - val twitterUrl = "https://twitter.com/${speaker.twitter.removePrefix("@")}" - val intent = Intent(Intent.ACTION_VIEW, twitterUrl.toUri()).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } catch (_: Exception) {} - }, - contentPadding = PaddingValues(0.dp), - modifier = Modifier.height(32.dp) - ) { - Text( - text = "@${speaker.twitter.removePrefix("@")}", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.secondary - ) - } - } - } - } - - if (!speaker.bio.isNullOrBlank()) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = speaker.bio, - style = MaterialTheme.typography.bodyMedium, - lineHeight = 22.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Se alle foredrag fra denne foreleseren", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) +private fun StatusBanner(title: String, text: String, container: androidx.compose.ui.graphics.Color, content: androidx.compose.ui.graphics.Color, onClick: (() -> Unit)? = null) { + Surface(color = container, onClick = onClick ?: {}, enabled = onClick != null, modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (title == "GÅR NÅ") Icons.Default.PlayCircle else Icons.Default.Info, null, tint = content) + Spacer(Modifier.width(12.dp)) + Column { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Black, color = content) + Text(text, style = MaterialTheme.typography.bodyMedium, color = content) } } } } +@Composable +private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(40.dp)) { + Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(20.dp)) } + } + Spacer(Modifier.width(12.dp)) + Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + } + } +} +@Composable +private fun SpeakerCard(speaker: Speaker, onClick: () -> Unit) { + Surface(onClick = onClick, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.Top) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.size(48.dp)) { + Box(contentAlignment = Alignment.Center) { Text(speaker.name.firstOrNull()?.uppercase() ?: "?", fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSecondaryContainer) } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(speaker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + speaker.twitter?.let { Text("@${it.removePrefix("@")}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) } + if (!speaker.bio.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text(speaker.bio, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, lineHeight = 20.sp) + } + Spacer(Modifier.height(6.dp)) + Text("Se foredrag fra denne foredragsholderen", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + } + } + } +} +@Composable +private fun EmptyDetail(onBackClick: () -> Unit, padding: PaddingValues, year: Int) { + Column(Modifier.fillMaxSize().padding(padding).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(Icons.Default.EventBusy, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(16.dp)) + Text("Foredraget ble ikke funnet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Programmet for JavaZone $year kan ha endret seg.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(16.dp)) + FilledTonalButton(onClick = onBackClick) { Text("Tilbake") } + } +} From 15ceac3a0833d8fe4df4317e668adc9494289e96 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:04:25 +0200 Subject: [PATCH 21/36] refactor: polish session detail hierarchy and metadata --- .../javazone/ui/detail/SessionDetailScreen.kt | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index 3a7665b..671b6dd 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -25,9 +25,6 @@ import com.olavbg.javazone.model.Session import com.olavbg.javazone.model.Speaker import com.olavbg.javazone.ui.components.FormatBadge import com.olavbg.javazone.ui.components.resolveVideoUrl -import com.olavbg.javazone.ui.theme.JavaGreen -import com.olavbg.javazone.ui.theme.WorkshopPurple -import com.olavbg.javazone.ui.theme.JavaBlue import com.olavbg.javazone.util.calculateSessionDurationMinutes import com.olavbg.javazone.util.formatDay import com.olavbg.javazone.util.formatTime @@ -53,17 +50,13 @@ fun SessionDetailScreen( var session by remember(sessionId, year) { mutableStateOf(null) } var loading by remember(sessionId, year) { mutableStateOf(true) } var simulatedTime by remember { mutableStateOf(Instant.now()) } - val offset by settingsRepository.simulatedTimeOffset.collectAsState(initial = 0L) - LaunchedEffect(offset) { simulatedTime = Instant.now().plusMillis(offset) } + LaunchedEffect(offset) { simulatedTime = Instant.now().plusMillis(offset) } LaunchedEffect(sessionId, year) { loading = true - session = if (isCurrentYear) { - repository.getSessions().first().find { it.id == sessionId } - } else { - repository.getArchiveSessions(year).find { it.id == sessionId } - } + session = if (isCurrentYear) repository.getSessions().first().find { it.id == sessionId } + else repository.getArchiveSessions(year).find { it.id == sessionId } loading = false } @@ -77,18 +70,11 @@ fun SessionDetailScreen( Text("JavaZone $year", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } }, - navigationIcon = { - IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Tilbake") } - }, + navigationIcon = { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Tilbake") } }, actions = { - val current = session - if (current != null && isCurrentYear) { + session?.takeIf { isCurrentYear }?.let { current -> IconButton(onClick = { scope.launch { repository.toggleFavorite(current.id, !current.isFavorite) } }) { - Icon( - if (current.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - "Favoritt", - tint = if (current.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current - ) + Icon(if (current.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Favoritt", tint = if (current.isFavorite) MaterialTheme.colorScheme.tertiary else LocalContentColor.current) } } } @@ -100,12 +86,12 @@ fun SessionDetailScreen( session == null -> EmptyDetail(onBackClick, padding, year) else -> DetailContent( session = session!!, + year = year, simulatedTime = simulatedTime, - isCurrentYear = isCurrentYear, onSpeakerClick = onSpeakerClick, onVideoClick = { - val url = session?.videoUrl?.let(::resolveVideoUrl) ?: return@DetailContent - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + val videoUrl = session?.videoUrl?.let(::resolveVideoUrl) ?: return@DetailContent + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, videoUrl.toUri())) } }, modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding()), bottomPadding = padding.calculateBottomPadding() + contentPadding.calculateBottomPadding() @@ -117,8 +103,8 @@ fun SessionDetailScreen( @Composable private fun DetailContent( session: Session, + year: Int, simulatedTime: Instant, - isCurrentYear: Boolean, onSpeakerClick: (String) -> Unit, onVideoClick: () -> Unit, modifier: Modifier, @@ -131,16 +117,14 @@ private fun DetailContent( val upcoming = start != null && simulatedTime < start val minutesToStart = start?.let { Duration.between(simulatedTime, it).toMinutes().coerceAtLeast(0) } - Column( - modifier = modifier.verticalScroll(rememberScrollState()).padding(bottom = bottomPadding + 32.dp) - ) { + Column(modifier.verticalScroll(rememberScrollState()).padding(bottom = bottomPadding + 32.dp)) { if (live) StatusBanner("GÅR NÅ", "${session.room} · ${Duration.between(simulatedTime, end).toMinutes().coerceAtLeast(0)} min igjen", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) else if (upcoming && minutesToStart != null && minutesToStart <= 60) StatusBanner("STARTER SNART", "Om $minutesToStart min · ${session.room}", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer) else if (finished && session.videoUrl != null) StatusBanner("AVSLUTTET", "Opptak er tilgjengelig", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer, onVideoClick) Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(horizontal = 20.dp, vertical = 24.dp)) { - Text("JavaZone $sessionYearLabel", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + Text("JavaZone $year", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) Spacer(Modifier.height(10.dp)) Text(session.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.ExtraBold) Spacer(Modifier.height(16.dp)) @@ -152,11 +136,7 @@ private fun DetailContent( } Column(Modifier.padding(20.dp)) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = .6f)) - ) { + Surface(shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = .6f))) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { InfoRow(Icons.Default.Schedule, "Tid", "${formatDay(session.startTimeZulu)}, ${formatTime(session.startTimeZulu)}–${formatTime(session.endTimeZulu)}") InfoRow(Icons.Default.LocationOn, "Rom", session.room) @@ -174,7 +154,7 @@ private fun DetailContent( Text(if (session.speakers.size == 1) "Foredragsholder" else "Foredragsholdere", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold) Spacer(Modifier.height(12.dp)) session.speakers.forEach { speaker -> - SpeakerCard(speaker, onClick = { onSpeakerClick(speaker.name) }) + SpeakerCard(speaker) { onSpeakerClick(speaker.name) } Spacer(Modifier.height(10.dp)) } } @@ -191,8 +171,6 @@ private fun DetailContent( } } -private const val sessionYearLabel = "Program" - @Composable private fun StatusBanner(title: String, text: String, container: androidx.compose.ui.graphics.Color, content: androidx.compose.ui.graphics.Color, onClick: (() -> Unit)? = null) { Surface(color = container, onClick = onClick ?: {}, enabled = onClick != null, modifier = Modifier.fillMaxWidth()) { From 2b6083c2ce3edec73d9929b6696c9f2d6cfa987e Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:07:56 +0200 Subject: [PATCH 22/36] fix: resolve SessionDetail compile issue --- .../java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index 671b6dd..bbe1d0e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri From de08a35724ec7e70393f998d0c64fefbf4591a89 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:08:09 +0200 Subject: [PATCH 23/36] fix: clear speaker filter with other filters --- .../java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt index 216657a..20b787e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt @@ -106,8 +106,8 @@ class TimelineViewModel(private val repository: SessionRepository, private val s fun setRoom(value: String?) { _selectedRoom.value = value } fun setFilterSpeaker(value: String?) { _filterSpeaker.value = value; if (value != null) _selectedDay.value = null } fun setSearchQuery(value: String) { _searchQuery.value = value } - fun clearFilters() { _onlyFavorites.value = false; _selectedFormat.value = null; _selectedLanguage.value = null; _selectedRoom.value = null; _searchQuery.value = "" } - fun activeFilterCount() = listOf(_onlyFavorites.value, _selectedFormat.value != null, _selectedLanguage.value != null, _selectedRoom.value != null, _searchQuery.value.isNotBlank()).count { it } + fun clearFilters() { _onlyFavorites.value = false; _selectedFormat.value = null; _selectedLanguage.value = null; _selectedRoom.value = null; _filterSpeaker.value = null; _searchQuery.value = "" } + fun activeFilterCount() = listOf(_onlyFavorites.value, _selectedFormat.value != null, _selectedLanguage.value != null, _selectedRoom.value != null, _filterSpeaker.value != null, _searchQuery.value.isNotBlank()).count { it } fun shouldScrollToNow() = !hasScrolledForContext fun markScrolledToNow() { hasScrolledForContext = true } fun toggleFavorite(session: Session) { if (_selectedYear.value == CURRENT_YEAR) viewModelScope.launch { repository.toggleFavorite(session.id, !session.isFavorite) } } From fef1ea4a30babb575a03f363aa5364d8ee385293 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:08:22 +0200 Subject: [PATCH 24/36] refine: expressive accessible year picker --- .../javazone/ui/components/YearPicker.kt | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt b/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt index bb010d3..83a98db 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/components/YearPicker.kt @@ -3,19 +3,29 @@ package com.olavbg.javazone.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,21 +41,37 @@ fun YearPickerSheet( ) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text("Velg JavaZone-år", style = androidx.compose.material3.MaterialTheme.typography.headlineSmall) - Text("Bytt program uten å forlate tidslinjen.", style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant) + Text("Velg JavaZone-år", style = MaterialTheme.typography.headlineSmall) + Text("Bytt program uten å forlate tidslinjen.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) LazyColumn( contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(years, key = { it }) { year -> - FilterChip( - selected = year == selectedYear, + val selected = year == selectedYear + Surface( onClick = { onYearSelected(year) }, - label = { Text(year.toString()) }, - modifier = Modifier.fillMaxWidth(), - ) + shape = MaterialTheme.shapes.large, + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = if (selected) 2.dp else 0.dp, + modifier = Modifier.fillMaxWidth().semantics { + role = Role.RadioButton + this.selected = selected + }, + ) { + Row(Modifier.padding(horizontal = 18.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = year.toString(), + style = if (selected) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.Bold, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (selected) Icon(Icons.Default.Check, contentDescription = "Valgt", tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + } } } } From 4b0de2292864e1677a916fefad0bb1d3d1c068b0 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 19:10:22 +0200 Subject: [PATCH 25/36] ci: add Android build verification workflow --- .github/workflows/android-build.yml | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/android-build.yml diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..e95a4c0 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,31 @@ +name: Android build verification + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Compile Android app + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Build debug APK + run: ./gradlew assembleDebug --stacktrace From 7b0928bd6d59a75225db5cd8a2af5df120edaeb1 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:05:23 +0200 Subject: [PATCH 26/36] fix: run Gradle wrapper through bash in CI --- .github/workflows/android-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index e95a4c0..dd0e68e 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -28,4 +28,4 @@ jobs: uses: android-actions/setup-android@v3 - name: Build debug APK - run: ./gradlew assembleDebug --stacktrace + run: bash ./gradlew assembleDebug --stacktrace From d1b478821ab9c770705b4b054db6c67af8c57c8c Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:17:05 +0200 Subject: [PATCH 27/36] fix: import rememberSaveable in app root --- app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt index fbafbb9..1c7b91a 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt @@ -3,6 +3,7 @@ package com.olavbg.javazone.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberNavBackStack From 1efe879e5915e66df3af3b8d8604703bd19bd9b5 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:17:21 +0200 Subject: [PATCH 28/36] fix: compile timeline rememberSaveable and trailing icon --- .../com/olavbg/javazone/ui/timeline/TimelineScreen.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt index 1f5a6a4..379343e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -112,7 +113,13 @@ fun TimelineScreen( onValueChange = viewModel::setSearchQuery, placeholder = { Text("Søk foredrag, foredragsholder eller rom…") }, leadingIcon = { Icon(Icons.Default.Search, null) }, - trailingIcon = if (searchQuery.isNotEmpty()) ({ IconButton(onClick = { viewModel.setSearchQuery("") }) { Icon(Icons.Default.Clear, "Nullstill") } }) else null, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchQuery("") }) { + Icon(Icons.Default.Clear, "Nullstill") + } + } + }, singleLine = true, shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp) From 5b0ee7d09489362cf27521ff4b6976d896047760 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:17:35 +0200 Subject: [PATCH 29/36] fix: opt in to experimental Material3 detail API --- .../java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index bbe1d0e..fcfb3f5 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionDetailScreen( sessionId: String, From 1fae7358e4d07fdb1650cd1a2cf4f13de5e8731c Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:34:10 +0200 Subject: [PATCH 30/36] refactor: centralize JavaZone year configuration --- app/src/main/java/com/olavbg/javazone/JavaZoneConfig.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 app/src/main/java/com/olavbg/javazone/JavaZoneConfig.kt diff --git a/app/src/main/java/com/olavbg/javazone/JavaZoneConfig.kt b/app/src/main/java/com/olavbg/javazone/JavaZoneConfig.kt new file mode 100644 index 0000000..49a2948 --- /dev/null +++ b/app/src/main/java/com/olavbg/javazone/JavaZoneConfig.kt @@ -0,0 +1,6 @@ +package com.olavbg.javazone + +object JavaZoneConfig { + const val CURRENT_YEAR = 2026 + const val FIRST_ARCHIVE_YEAR = 2014 +} From 73cfd187f691d69a52d4ddfbb6a7a8c2f8a364e2 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:34:14 +0200 Subject: [PATCH 31/36] refactor: use centralized current year in navigation --- .../java/com/olavbg/javazone/ui/navigation/NavDestinations.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt index 452fa10..8bcea0e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/navigation/NavDestinations.kt @@ -1,6 +1,7 @@ package com.olavbg.javazone.ui.navigation import androidx.navigation3.runtime.NavKey +import com.olavbg.javazone.JavaZoneConfig import kotlinx.serialization.Serializable @Serializable @@ -11,7 +12,7 @@ sealed interface NavDestination : NavKey { @Serializable data class SessionDetail( val sessionId: String, - val year: Int = 2026, + val year: Int = JavaZoneConfig.CURRENT_YEAR, ) : NavDestination @Serializable From 8bd9f043a82ccc45b7140950dff32b799313fd5c Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:34:29 +0200 Subject: [PATCH 32/36] fix: keep timeline groups separated by conference day --- .../com/olavbg/javazone/ui/timeline/TimelineViewModel.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt index 20b787e..07da0bb 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineViewModel.kt @@ -2,6 +2,7 @@ package com.olavbg.javazone.ui.timeline import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.olavbg.javazone.JavaZoneConfig import com.olavbg.javazone.data.repository.SessionRepository import com.olavbg.javazone.data.repository.SettingsRepository import com.olavbg.javazone.model.Session @@ -15,7 +16,7 @@ import java.time.format.DateTimeFormatter import java.util.Locale class TimelineViewModel(private val repository: SessionRepository, private val settingsRepository: SettingsRepository) : ViewModel() { - companion object { const val CURRENT_YEAR = 2026 } + companion object { const val CURRENT_YEAR = JavaZoneConfig.CURRENT_YEAR } private val _selectedYear = MutableStateFlow(CURRENT_YEAR) val selectedYear = _selectedYear.asStateFlow() private val _selectedDay = MutableStateFlow(null) @@ -75,8 +76,10 @@ class TimelineViewModel(private val repository: SessionRepository, private val s if (query.isNotBlank()) { val q = query.trim().lowercase(); filtered = filtered.filter { it.title.lowercase().contains(q) || it.room.lowercase().contains(q) || it.abstract.lowercase().contains(q) || it.speakers.any { s -> s.name.lowercase().contains(q) } } } filtered }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val groupedSessions = sessions.map { list -> list.sortedWith(compareBy { it.startTimeZulu }.thenBy { extractRoomNumber(it.room) }.thenBy { it.room }).groupBy { formatTime(it.startTimeZulu) } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + val groupedSessions = sessions.map { list -> + list.sortedWith(compareBy { it.startTimeZulu }.thenBy { extractRoomNumber(it.room) }.thenBy { it.room }) + .groupBy { "${getDayFromZulu(it.startTimeZulu)}|${formatTime(it.startTimeZulu)}" } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) init { viewModelScope.launch { try { repository.refreshSessions() } finally { _isLoading.value = false } } From 2100a115cd3c891141fc4602b08585c2ad956514 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:34:48 +0200 Subject: [PATCH 33/36] fix: guard timeline progress calculation --- .../olavbg/javazone/ui/timeline/TimelineScreen.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt index 379343e..29438f8 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/timeline/TimelineScreen.kt @@ -197,9 +197,10 @@ private fun TimelineList( contentPadding: PaddingValues ) { LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 32.dp + contentPadding.calculateBottomPadding()), modifier = Modifier.fillMaxSize()) { - grouped.forEach { (time, sessions) -> + grouped.forEach { (dayAndTime, sessions) -> + val time = dayAndTime.substringAfter('|', dayAndTime) val live = sessions.any { isActive(it, currentTime) } - stickyHeader(key = "time-$time") { + stickyHeader(key = "time-$dayAndTime") { Surface(color = MaterialTheme.colorScheme.background.copy(alpha = .96f)) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically) { Surface(shape = RoundedCornerShape(14.dp), color = if (live) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant) { @@ -279,6 +280,11 @@ private fun languageLabel(lang: String) = if (lang.contains("no", true)) "🇳 private fun formatTime(zulu: String): String = try { java.time.Instant.parse(zulu).atZone(java.time.ZoneId.of("Europe/Oslo")).toLocalTime().toString().take(5) } catch (_: Exception) { "--:--" } private fun isActive(s: Session, now: Instant) = try { now >= Instant.parse(s.startTimeZulu) && now < Instant.parse(s.endTimeZulu) } catch (_: Exception) { false } private fun isPast(s: Session, now: Instant) = try { now > Instant.parse(s.endTimeZulu) } catch (_: Exception) { false } -private fun progress(s: Session, now: Instant): Float = try { val a=Instant.parse(s.startTimeZulu); val b=Instant.parse(s.endTimeZulu); (Duration.between(a, now).toMillis().toFloat()/Duration.between(a,b).toMillis()).coerceIn(0f,1f) } catch (_: Exception) { 0f } +private fun progress(s: Session, now: Instant): Float = try { + val a = Instant.parse(s.startTimeZulu) + val b = Instant.parse(s.endTimeZulu) + val duration = Duration.between(a, b).toMillis() + if (duration <= 0L) 0f else (Duration.between(a, now).toMillis().toFloat() / duration).coerceIn(0f, 1f) +} catch (_: Exception) { 0f } private fun remaining(s: Session, now: Instant): Long = try { Duration.between(now, Instant.parse(s.endTimeZulu)).toMinutes().coerceAtLeast(0) } catch (_: Exception) { 0 } private fun findFirstActiveOrUpcomingIndex(grouped: Map>, now: Instant): Int { var index=0; var future=-1; for ((_, list) in grouped) { if (list.any { isActive(it,now) }) return index; if (future<0 && list.firstOrNull()?.let { try { Instant.parse(it.startTimeZulu)>now } catch (_:Exception){false} } == true) future=index; index += 1+list.size }; return future.coerceAtLeast(0) } From b8d8ccfda3c1d76a1c911e075923329aa39240c0 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:35:07 +0200 Subject: [PATCH 34/36] fix: react to current session data updates on detail screen --- .../javazone/ui/detail/SessionDetailScreen.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index fcfb3f5..a5fb31e 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri +import com.olavbg.javazone.JavaZoneConfig import com.olavbg.javazone.data.repository.SessionRepository import com.olavbg.javazone.data.repository.SettingsRepository import com.olavbg.javazone.model.Session @@ -48,7 +49,7 @@ fun SessionDetailScreen( ) { val scope = rememberCoroutineScope() val context = LocalContext.current - val isCurrentYear = year == 2026 + val isCurrentYear = year == JavaZoneConfig.CURRENT_YEAR var session by remember(sessionId, year) { mutableStateOf(null) } var loading by remember(sessionId, year) { mutableStateOf(true) } var simulatedTime by remember { mutableStateOf(Instant.now()) } @@ -57,9 +58,15 @@ fun SessionDetailScreen( LaunchedEffect(offset) { simulatedTime = Instant.now().plusMillis(offset) } LaunchedEffect(sessionId, year) { loading = true - session = if (isCurrentYear) repository.getSessions().first().find { it.id == sessionId } - else repository.getArchiveSessions(year).find { it.id == sessionId } - loading = false + if (isCurrentYear) { + repository.getSessions().collect { sessions -> + session = sessions.find { it.id == sessionId } + if (session != null) loading = false + } + } else { + session = repository.getArchiveSessions(year).find { it.id == sessionId } + loading = false + } } Scaffold( From 1617aa44c5adf5023f01cfc3b9647e6a7e5315e3 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:35:15 +0200 Subject: [PATCH 35/36] refactor: use centralized year range in app navigation --- app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt index 1c7b91a..4e5d353 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/JavaZoneApp.kt @@ -9,6 +9,7 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.scene.SinglePaneSceneStrategy import androidx.navigation3.ui.NavDisplay +import com.olavbg.javazone.JavaZoneConfig import com.olavbg.javazone.data.repository.SessionRepository import com.olavbg.javazone.data.repository.SettingsRepository import com.olavbg.javazone.notifications.ReminderManager @@ -30,7 +31,7 @@ fun JavaZoneApp( ) { val timelineViewModel: TimelineViewModel = viewModel(factory = TimelineViewModelFactory(repository, settingsRepository)) var yearPickerVisible by rememberSaveable { mutableStateOf(false) } - val years = remember { (2014..TimelineViewModel.CURRENT_YEAR).toList().reversed() } + val years = remember { (JavaZoneConfig.FIRST_ARCHIVE_YEAR..JavaZoneConfig.CURRENT_YEAR).toList().reversed() } val backStack = if (initialSessionId != null) { rememberNavBackStack(NavDestination.Timeline, NavDestination.SessionDetail(initialSessionId)) From 803e063514ffa9c61bd5ee4a8d080b96bc348192 Mon Sep 17 00:00:00 2001 From: Olav Blaaflat Date: Tue, 8 Sep 2026 20:35:33 +0200 Subject: [PATCH 36/36] fix: allow detail screen to recover from initially empty session flow --- .../java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt index a5fb31e..1d25fca 100644 --- a/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt +++ b/app/src/main/java/com/olavbg/javazone/ui/detail/SessionDetailScreen.kt @@ -30,7 +30,6 @@ import com.olavbg.javazone.ui.components.resolveVideoUrl import com.olavbg.javazone.util.calculateSessionDurationMinutes import com.olavbg.javazone.util.formatDay import com.olavbg.javazone.util.formatTime -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant @@ -61,7 +60,7 @@ fun SessionDetailScreen( if (isCurrentYear) { repository.getSessions().collect { sessions -> session = sessions.find { it.id == sessionId } - if (session != null) loading = false + loading = false } } else { session = repository.getArchiveSessions(year).find { it.id == sessionId }