From e321ec1b1e8e473843cd971200b70aa0b994aa28 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:17:21 -0400 Subject: [PATCH 01/29] Match Android color tokens to web mobile --- .../java/ca/gapwise/android/core/designsystem/Theme.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/core/designsystem/Theme.kt b/app/src/main/java/ca/gapwise/android/core/designsystem/Theme.kt index 88dc9dd..4c2fdc8 100644 --- a/app/src/main/java/ca/gapwise/android/core/designsystem/Theme.kt +++ b/app/src/main/java/ca/gapwise/android/core/designsystem/Theme.kt @@ -6,10 +6,14 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -// sRGB equivalents of the canonical Gapwise web OKLCH tokens in src/brand-blue.css. +// Exact sRGB equivalents of the canonical Gapwise web OKLCH tokens in src/brand-blue.css. +// Keep these values in lock-step with the web app: the native shell should be visually +// indistinguishable from the 400px mobile web surface, not merely "Android themed". private val LightColors = lightColorScheme( primary = Color(0xFF007AD4), onPrimary = Color(0xFFFCFDFD), + tertiary = Color(0xFF007DD3), // --accent + onTertiary = Color(0xFFFCFDFD), background = Color(0xFFF9FAFB), onBackground = Color(0xFF101214), surface = Color(0xFFFEFEFF), @@ -24,8 +28,10 @@ private val LightColors = lightColorScheme( ) private val DarkColors = darkColorScheme( - primary = Color(0xFF33A3F1), + primary = Color(0xFF009AF1), onPrimary = Color(0xFFFCFDFD), + tertiary = Color(0xFF33A3F1), // --accent + onTertiary = Color(0xFFFCFDFD), background = Color(0xFF040405), onBackground = Color(0xFFE9EBEE), surface = Color(0xFF07080A), From f19153261f2b7998db42a89c08015896db0af060 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:17:37 -0400 Subject: [PATCH 02/29] Add shared web-mobile parity components --- .../core/designsystem/WebMobileComponents.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/src/main/java/ca/gapwise/android/core/designsystem/WebMobileComponents.kt diff --git a/app/src/main/java/ca/gapwise/android/core/designsystem/WebMobileComponents.kt b/app/src/main/java/ca/gapwise/android/core/designsystem/WebMobileComponents.kt new file mode 100644 index 0000000..7557cfb --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/core/designsystem/WebMobileComponents.kt @@ -0,0 +1,129 @@ +package ca.gapwise.android.core.designsystem + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 + +val WebSurfaceShape = RoundedCornerShape(12.dp) +val WebControlShape = RoundedCornerShape(9.dp) +val WebInnerShape = RoundedCornerShape(7.dp) + +/** The plain bordered card used by the mobile web app's .surface rule. */ +@Composable +fun WebSurface( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier, + shape = WebSurfaceShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, + tonalElevation = 0.dp, + ) { + Column(modifier = Modifier.padding(padding), content = content) + } +} + +/** Web-style uppercase kicker/eyebrow. */ +@Composable +fun WebEyebrow( + text: String, + modifier: Modifier = Modifier, + accent: Boolean = true, +) { + Text( + text = text.uppercase(), + modifier = modifier, + color = if (accent) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.15.sp, + ) +} + +/** A single cell from the web segmented controls. */ +@Composable +fun WebSegmentButton( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + minHeight: Dp = 40.dp, + secondaryLabel: String? = null, +) { + val selectedBackground = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.10f) + val selectedColor = MaterialTheme.colorScheme.tertiary + val idleColor = MaterialTheme.colorScheme.onSurfaceVariant + + Box( + modifier = modifier + .heightIn(min = minHeight) + .background( + color = if (selected) selectedBackground else Color.Transparent, + shape = WebInnerShape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + color = if (selected) selectedColor else idleColor, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + if (secondaryLabel != null) { + Text( + text = secondaryLabel, + color = if (selected) selectedColor.copy(alpha = 0.78f) else idleColor.copy(alpha = 0.78f), + fontSize = 9.5.sp, + lineHeight = 11.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 2.dp), + maxLines = 1, + ) + } + } + } +} + +@Composable +fun WebSegmentContainer( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background, WebControlShape) + .padding(3.dp), + content = content, + ) +} From b49fa1cd06823f141fc644716750b5978265dad3 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:18:18 -0400 Subject: [PATCH 03/29] Rebuild timetable to mirror web mobile layout --- .../feature/timetable/TimetableScreen.kt | 404 ++++++++++++------ 1 file changed, 280 insertions(+), 124 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt b/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt index 7347647..458ae20 100644 --- a/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt @@ -1,29 +1,44 @@ package ca.gapwise.android.feature.timetable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.Place import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gapwise.android.core.designsystem.WebControlShape +import ca.gapwise.android.core.designsystem.WebEyebrow +import ca.gapwise.android.core.designsystem.WebSegmentButton +import ca.gapwise.android.core.designsystem.WebSurface import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term import ca.gapwise.android.core.model.formatTime @@ -67,70 +82,88 @@ fun TimetableScreen( ) } val dayMeetings = termMeetings.filter { it.weekday == selectedDay }.sortedBy { it.startTime } + val dayClasses = dayMeetings.filterNot { it.isAssessmentWindow } + val reservedCount = dayMeetings.size - dayClasses.size LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column { - Text( - text = "DAY TIMETABLE", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - Text( - text = selectedDay.name.lowercase().replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - } - OutlinedButton(onClick = onImport) { - Text("Replace") + WebSurface { + WebEyebrow("Day timetable") + Text( + text = selectedDay.displayName(), + modifier = Modifier.padding(top = 6.dp), + fontSize = 24.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.7).sp, + ) + Text( + text = when { + dayMeetings.isEmpty() -> "Nothing scheduled in ${selectedTerm.label}" + dayClasses.isEmpty() -> "0 classes · $reservedCount ${if (reservedCount == 1) "reserved window" else "reserved windows"}" + else -> buildString { + append("${dayClasses.size} ${if (dayClasses.size == 1) "class" else "classes"}") + if (reservedCount > 0) append(" · $reservedCount reserved") + append(" · ${formatTime(dayClasses.first().startTime)} – ${formatTime(dayClasses.last().endTime)}") } - } - Text( - text = if (dayMeetings.isEmpty()) { - "Nothing scheduled in ${selectedTerm.label}." - } else { - "${dayMeetings.size} ${if (dayMeetings.size == 1) "class" else "classes"} · ${formatTime(dayMeetings.first().startTime)} – ${formatTime(dayMeetings.last().endTime)}" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + }, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 20.sp, + ) - if (terms.size > 1) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(terms) { term -> - FilterChip( - selected = term == selectedTerm, + if (terms.size > 1) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = WebControlShape, + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, + ) { + Row(modifier = Modifier.padding(3.dp)) { + terms.forEach { term -> + WebSegmentButton( + label = term.label, + selected = selectedTerm == term, onClick = { selectedTerm = term }, - label = { Text(term.label) }, + modifier = Modifier.weight(1f), + minHeight = 38.dp, ) } } } + } - LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - items(visibleDays) { day -> - val count = termMeetings.count { it.weekday == day } - FilterChip( - selected = day == selectedDay, + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = WebControlShape, + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, + ) { + Row(modifier = Modifier.padding(3.dp)) { + visibleDays.forEach { day -> + val dayEvents = termMeetings.filter { it.weekday == day } + val reserved = dayEvents.count { it.isAssessmentWindow } + val count = dayEvents.size - reserved + WebSegmentButton( + label = day.shortName(), + secondaryLabel = buildString { + append(if (count == 0) "–" else count.toString()) + if (reserved > 0) append(" · R$reserved") + }, + selected = selectedDay == day, onClick = { selectedDay = day }, - label = { Text("${day.name.take(3).lowercase().replaceFirstChar(Char::titlecase)} ${if (count == 0) "–" else count}") }, + modifier = Modifier.weight(1f), + minHeight = 48.dp, ) } } @@ -150,70 +183,192 @@ fun TimetableScreen( if (dayMeetings.isEmpty()) { item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + WebSurface( + padding = PaddingValues(horizontal = 20.dp, vertical = 34.dp), ) { - Column(modifier = Modifier.padding(20.dp)) { - Text("Your ${selectedDay.name.lowercase().replaceFirstChar(Char::titlecase)} is clear", fontWeight = FontWeight.SemiBold) - Text( - text = "Pick another day to review your imported ACORN schedule.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) - } + Text( + text = "Your ${selectedDay.displayName()} is clear", + modifier = Modifier.fillMaxWidth(), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Pick another day to review the classes in your imported ACORN schedule.", + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 21.sp, + ) } } } else { - items(dayMeetings, key = { it.id }) { meeting -> - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = meeting.courseCode, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = meeting.campus.shortName, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) + item { + WebSurface(padding = PaddingValues(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + dayMeetings.forEach { meeting -> + MeetingCard(meeting) } + } + } + } + } + } +} + +@Composable +private fun MeetingCard(meeting: Meeting) { + val accent = MaterialTheme.colorScheme.tertiary + val location = locationPresentation(meeting) + + Surface( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, + ) { + Row { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(accent), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(13.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = meeting.courseCode, + fontSize = 15.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Surface( + modifier = Modifier.padding(start = 8.dp), + shape = RoundedCornerShape(5.dp), + color = accent.copy(alpha = 0.11f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.28f)), + ) { Text( - text = listOf(meeting.activityType.name, meeting.sectionCode) - .filter(String::isNotBlank) - .joinToString(" · "), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = meeting.activityLabel, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), + color = accent, + fontSize = 9.5.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Bold, ) + } + Icon( + imageVector = Icons.Outlined.ChevronRight, + contentDescription = null, + modifier = Modifier.padding(start = 6.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.padding(top = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.AccessTime, + contentDescription = null, + tint = accent, + ) + Text( + text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Row( + modifier = Modifier.padding(top = 6.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.Place, + contentDescription = null, + tint = accent, + ) + Column { Text( - text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", - style = MaterialTheme.typography.bodyMedium, + text = location.first, + fontSize = 12.sp, + lineHeight = 15.sp, fontWeight = FontWeight.SemiBold, ) - Text( - text = meeting.locationLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + location.second?.let { floor -> + Text( + text = floor, + modifier = Modifier.padding(top = 2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Medium, + ) + } } } + + if (meeting.courseName.isNotBlank()) { + Text( + text = meeting.courseName, + modifier = Modifier.padding(top = 9.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + lineHeight = 18.sp, + maxLines = 2, + ) + } } } } } +private fun locationPresentation(meeting: Meeting): Pair { + if (meeting.isAssessmentWindow) return "Reserved assessment window" to "Only active when announced" + val code = meeting.buildingCode + val room = meeting.room + val building = when (code) { + "MN" -> "Maanjiwe nendamowinan" + "DH" -> "Deerfield Hall" + "IB" -> "Instructional Centre" + "DV" -> "William G. Davis Building" + "CCT" -> "Communication, Culture and Technology Building" + "HM" -> "Hazel McCallion Academic Learning Centre" + "KN" -> "Kaneff Centre" + "RAWC" -> "Recreation, Athletics and Wellness Centre" + "XR" -> "Student Centre" + "HB" -> "Terrence Donnelly Health Sciences Complex" + else -> null + } + val compact = when { + building != null && room != null -> "$building · $room" + building != null -> building + else -> meeting.locationLabel + } + val floor = room?.firstOrNull()?.digitToIntOrNull()?.takeIf { it > 0 }?.let { value -> + val suffix = when (value) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } + "$value$suffix floor" + } + return compact to floor +} + @Composable private fun EmptyTimetable(importStatus: String?, onImport: () -> Unit) { LazyColumn( @@ -221,29 +376,27 @@ private fun EmptyTimetable(importStatus: String?, onImport: () -> Unit) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + WebSurface(padding = PaddingValues(20.dp)) { + WebEyebrow("Timetable import") + Text( + text = "Add your timetable", + modifier = Modifier.padding(top = 7.dp), + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Import your ACORN .ics calendar. UTM, St. George, Scarborough, and mixed-campus schedules are handled in one timetable.", + modifier = Modifier.padding(top = 9.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 21.sp, + ) + Button( + onClick = onImport, + modifier = Modifier.padding(top = 14.dp), + shape = RoundedCornerShape(9.dp), ) { - Text( - text = "TIMETABLE IMPORT", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - Text("Add your timetable", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) - Text( - text = "Import your ACORN .ics calendar. UTM, St. George, Scarborough, and mixed-campus schedules are handled in one timetable.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onImport) { - Text("Import ACORN calendar") - } + Text("Import ACORN calendar") } } } @@ -255,6 +408,9 @@ private fun EmptyTimetable(importStatus: String?, onImport: () -> Unit) { } } +private fun DayOfWeek.displayName(): String = name.lowercase().replaceFirstChar(Char::titlecase) +private fun DayOfWeek.shortName(): String = name.take(3).lowercase().replaceFirstChar(Char::titlecase) + private fun currentTerm(): Term = when (LocalDate.now().monthValue) { in 1..4 -> Term.WINTER in 5..8 -> Term.SUMMER From 99818796d598c77ac9b72fa40185c35ec5493f66 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:18:58 -0400 Subject: [PATCH 04/29] Match Today screen to web mobile hierarchy --- .../android/feature/today/TodayScreen.kt | 391 +++++++++++++++--- 1 file changed, 334 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/today/TodayScreen.kt b/app/src/main/java/ca/gapwise/android/feature/today/TodayScreen.kt index 25358c4..e51d546 100644 --- a/app/src/main/java/ca/gapwise/android/feature/today/TodayScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/today/TodayScreen.kt @@ -1,125 +1,402 @@ package ca.gapwise.android.feature.today +import androidx.compose.foundation.BorderStroke 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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.CheckCircleOutline +import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Place import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gapwise.android.core.designsystem.WebEyebrow +import ca.gapwise.android.core.designsystem.WebSurface import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term import ca.gapwise.android.core.model.formatTime +import java.time.DayOfWeek import java.time.LocalDate +import java.time.LocalTime @Composable fun TodayScreen( meetings: List, importStatus: String?, onImport: () -> Unit, + onDayRoute: () -> Unit, ) { + if (meetings.isEmpty()) { + EmptyToday(importStatus = importStatus, onImport = onImport) + return + } + val today = LocalDate.now() val currentTerm = termForMonth(today.monthValue) - val todayMeetings = meetings - .filter { - it.term == currentTerm && - it.weekday == today.dayOfWeek && - !it.isAssessmentWindow + val termMeetings = meetings + .filter { it.term == currentTerm && !it.isAssessmentWindow } + .sortedWith(compareBy({ it.weekday.value }, { it.startTime })) + val todayMeetings = termMeetings.filter { it.weekday == today.dayOfWeek }.sortedBy { it.startTime } + val now = LocalTime.now() + val nowMinutes = now.hour * 60 + now.minute + val active = todayMeetings.firstOrNull { nowMinutes in it.startTime until it.endTime } + val nextToday = todayMeetings.firstOrNull { it.startTime > nowMinutes } + val nextOccurrence = findNextOccurrence(termMeetings, today.dayOfWeek) + + val gapsToday = calculateGaps(todayMeetings) + val termGapCount = DayOfWeek.entries.sumOf { day -> calculateGaps(termMeetings.filter { it.weekday == day }).size } + val scheduledMinutes = todayMeetings.sumOf { (it.endTime - it.startTime).coerceAtLeast(0) } + val openMinutes = if (todayMeetings.size > 1) { + (todayMeetings.last().endTime - todayMeetings.first().startTime - scheduledMinutes).coerceAtLeast(0) + } else { + 0 + } + + val primaryTitle: String + val primaryDetail: String + when { + active != null -> { + primaryTitle = "Now: ${active.courseCode}" + primaryDetail = active.courseName.ifBlank { "Until ${formatTime(active.endTime)}" } + } + nextToday != null -> { + primaryTitle = "Next: ${nextToday.courseCode}" + primaryDetail = "${formatTime(nextToday.startTime)} · starts in ${compactDuration(nextToday.startTime - nowMinutes)}" + } + todayMeetings.isNotEmpty() -> { + primaryTitle = "Done for today" + primaryDetail = nextOccurrence?.let { occurrenceLead(it) } ?: "No more classes are scheduled in this term." } - .sortedBy { it.startTime } + else -> { + primaryTitle = "No classes today" + primaryDetail = nextOccurrence?.let { occurrenceLead(it) } ?: "No later classes are scheduled in this term." + } + } LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + WebSurface(padding = PaddingValues(17.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.CalendarToday, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + ) + WebEyebrow( + text = "Today · ${today.dayOfWeek.displayName()}", + modifier = Modifier.padding(start = 7.dp), + accent = false, + ) + } + Text( + text = primaryTitle, + modifier = Modifier.padding(top = 11.dp), + fontSize = 27.sp, + lineHeight = 29.sp, + fontWeight = FontWeight.Medium, + letterSpacing = (-0.9).sp, + ) + Text( + text = primaryDetail, + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + + val focusMeeting = active ?: nextToday + if (focusMeeting != null) { + HorizontalDivider( + modifier = Modifier.padding(top = 14.dp, bottom = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + ) + Text( + text = focusMeeting.locationLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + lineHeight = 20.sp, + ) + } + } + + Button( + onClick = onDayRoute, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = RoundedCornerShape(9.dp), ) { - Text( - text = "GAPWISE FOR U OF T", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, + Icon(imageVector = Icons.Outlined.Navigation, contentDescription = null) + Text("Day route", modifier = Modifier.padding(start = 7.dp), fontWeight = FontWeight.SemiBold) + } + } + } + + item { + TodayStats( + classes = todayMeetings.size, + planned = 0, + gaps = gapsToday.size, + openMinutes = openMinutes, + ) + } + + item { + Column { + WebEyebrow("My day", accent = false) + Text( + text = "Timeline", + modifier = Modifier.padding(top = 5.dp), + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + + item { + if (todayMeetings.isEmpty()) { + WebSurface(padding = PaddingValues(horizontal = 18.dp, vertical = 30.dp)) { + Icon( + imageVector = Icons.Outlined.CheckCircleOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = today.dayOfWeek.name.lowercase().replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.headlineSmall, + text = "Nothing scheduled today", + modifier = Modifier.padding(top = 13.dp), + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, ) Text( - text = when { - meetings.isEmpty() -> "Import your ACORN calendar to see your day." - todayMeetings.isEmpty() -> "Nothing scheduled today in ${currentTerm.label}." - todayMeetings.size == 1 -> "1 class today" - else -> "${todayMeetings.size} classes today" - }, - style = MaterialTheme.typography.bodyMedium, + text = "Your day is open.", + modifier = Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, ) - if (meetings.isEmpty()) { - Button(onClick = onImport) { - Text("Import ACORN calendar") + } + } else { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + todayMeetings.forEachIndexed { index, meeting -> + TimelineRow( + meeting = meeting, + active = nowMinutes in meeting.startTime until meeting.endTime, + passed = nowMinutes >= meeting.endTime, + ) + if (index != todayMeetings.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } } } } } } + item { + Text( + text = "${termMeetings.size} meetings in ${currentTerm.label} · $termGapCount ${if (termGapCount == 1) "gap" else "gaps"} in the term", + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 14.sp, + ) + } + if (importStatus != null) { item { - Text( - text = importStatus, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 2.dp), - ) + Text(importStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } + } +} + +@Composable +private fun TodayStats(classes: Int, planned: Int, gaps: Int, openMinutes: Int) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row { + StatCell("Classes", classes.toString(), Modifier.weight(1f)) + StatCell("Planned", planned.toString(), Modifier.weight(1f), divider = true) + StatCell("Gaps", gaps.toString(), Modifier.weight(1f), divider = true) + StatCell("Open", compactDuration(openMinutes), Modifier.weight(1f), divider = true) + } + } +} - items(todayMeetings, key = { it.id }) { meeting -> - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), +@Composable +private fun StatCell(label: String, value: String, modifier: Modifier, divider: Boolean = false) { + Row(modifier = modifier) { + if (divider) { + androidx.compose.material3.VerticalDivider( + modifier = Modifier.padding(vertical = 0.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp)) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 9.5.sp, maxLines = 1) + Text(value, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + } + } +} + +@Composable +private fun TimelineRow(meeting: Meeting, active: Boolean, passed: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Column(modifier = Modifier.padding(top = 1.dp)) { + Text(formatTime(meeting.startTime), fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text( + formatTime(meeting.endTime), + modifier = Modifier.padding(top = 3.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + ) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(meeting.courseCode, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + Surface( + modifier = Modifier.padding(start = 7.dp), + shape = RoundedCornerShape(5.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Text( - text = meeting.courseCode, - style = MaterialTheme.typography.titleMedium, + meeting.activityLabel, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + fontSize = 9.sp, fontWeight = FontWeight.Bold, ) + } + if (active || passed) { Text( - text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "${meeting.locationLabel} · ${meeting.campus.shortName}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = if (active) "Now" else "Done", + modifier = Modifier.padding(start = 7.dp), + color = if (active) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, ) } } + if (meeting.courseName.isNotBlank()) { + Text( + meeting.courseName, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + maxLines = 1, + ) + } + Row( + modifier = Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Icon(imageVector = Icons.Outlined.Place, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(meeting.locationLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, maxLines = 1) + } + } + } +} + +private data class NextOccurrence(val meeting: Meeting, val daysAway: Int) + +private fun findNextOccurrence(meetings: List, today: DayOfWeek): NextOccurrence? = meetings + .map { meeting -> + val delta = (meeting.weekday.value - today.value + 7) % 7 + NextOccurrence(meeting, if (delta == 0) 7 else delta) + } + .minWithOrNull(compareBy({ it.daysAway }, { it.meeting.startTime })) + +private fun occurrenceLead(next: NextOccurrence): String { + val whenLabel = when (next.daysAway) { + 1 -> "Tomorrow" + else -> next.meeting.weekday.displayName() + } + return "$whenLabel starts at ${formatTime(next.meeting.startTime)} in ${next.meeting.locationLabel}" +} + +private fun calculateGaps(meetings: List): List = meetings + .sortedBy { it.startTime } + .zipWithNext() + .mapNotNull { (from, to) -> (to.startTime - from.endTime).takeIf { it > 0 } } + +private fun compactDuration(minutes: Int): String = when { + minutes <= 0 -> "0m" + minutes < 60 -> "${minutes}m" + minutes % 60 == 0 -> "${minutes / 60}h" + else -> "${minutes / 60}h ${minutes % 60}m" +} + +@Composable +private fun EmptyToday(importStatus: String?, onImport: () -> Unit) { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + WebSurface(padding = PaddingValues(20.dp)) { + WebEyebrow("Today") + Text("Your day starts with your timetable", modifier = Modifier.padding(top = 7.dp), fontSize = 23.sp, fontWeight = FontWeight.SemiBold) + Text( + "Import your ACORN calendar to see what is next, your gaps, and your route.", + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 21.sp, + ) + Button(onClick = onImport, modifier = Modifier.padding(top = 14.dp), shape = RoundedCornerShape(9.dp)) { + Text("Import ACORN calendar") + } + } } + if (importStatus != null) item { Text(importStatus, style = MaterialTheme.typography.bodySmall) } } } +private fun DayOfWeek.displayName(): String = name.lowercase().replaceFirstChar(Char::titlecase) + private fun termForMonth(month: Int): Term = when (month) { in 1..4 -> Term.WINTER in 5..8 -> Term.SUMMER From bf4c06c532d66adffb177bc235589229c6b63188 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:19:39 -0400 Subject: [PATCH 05/29] Rebuild gap plan to mirror web mobile presentation --- .../android/feature/gapplan/GapPlanScreen.kt | 435 ++++++++++++++---- 1 file changed, 338 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt b/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt index e6f3503..b1fab94 100644 --- a/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt @@ -1,27 +1,39 @@ package ca.gapwise.android.feature.gapplan +import androidx.compose.foundation.BorderStroke 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Restaurant +import androidx.compose.material.icons.outlined.Route +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.PeopleOutline import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gapwise.android.core.designsystem.WebEyebrow +import ca.gapwise.android.core.designsystem.WebSurface import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term import ca.gapwise.android.core.model.formatTime @@ -32,7 +44,9 @@ private data class GapPlanItem( val from: Meeting, val to: Meeting, val minutes: Int, -) +) { + val usableMinutes: Int get() = (minutes - 17).coerceAtLeast(0) +} @Composable fun GapPlanScreen( @@ -40,117 +54,298 @@ fun GapPlanScreen( onImport: () -> Unit, ) { if (meetings.isEmpty()) { - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), + EmptyGaps(onImport) + return + } + + val selectedTerm = currentTerm() + val termMeetings = meetings.filter { it.term == selectedTerm } + val gaps = remember(termMeetings) { calculateGaps(termMeetings) } + val groups = remember(gaps) { gaps.groupBy { it.from.weekday }.toSortedMap(compareBy(DayOfWeek::getValue)) } + val totalUsable = gaps.sumOf { it.usableMinutes } + + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Column { + WebEyebrow("Between classes", accent = false) + Text( + text = "Gap plan", + modifier = Modifier.padding(top = 5.dp), + fontSize = 24.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + letterSpacing = (-0.6).sp, + ) + Text( + text = "See what actually fits after walking time, setup, and the buffer before your next class.", + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 21.sp, + ) + Row( + modifier = Modifier.padding(top = 13.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + OutlinedButton( + onClick = { }, + shape = RoundedCornerShape(8.dp), + ) { + Icon(imageVector = Icons.Outlined.Tune, contentDescription = null) + Text("Tune", modifier = Modifier.padding(start = 6.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + OutlinedButton( + onClick = { }, + shape = RoundedCornerShape(8.dp), ) { + Icon(imageVector = Icons.Outlined.PeopleOutline, contentDescription = null) + Text("Friend gaps", modifier = Modifier.padding(start = 6.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + GapStats(gaps = gaps.size, usableMinutes = totalUsable, days = groups.size) + HorizontalDivider( + modifier = Modifier.padding(top = 15.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + + if (groups.isEmpty()) { + item { + WebSurface(padding = PaddingValues(horizontal = 18.dp, vertical = 30.dp)) { + Text("No gaps in this term", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text( + "Your scheduled classes are back to back, or there is only one class on each day.", + modifier = Modifier.padding(top = 6.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + lineHeight = 20.sp, + ) + } + } + } + + groups.forEach { (weekday, dayGaps) -> + item(key = weekday) { + DayGapSection(weekday = weekday, gaps = dayGaps) + } + } + } +} + +@Composable +private fun GapStats(gaps: Int, usableMinutes: Int, days: Int) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row { + GapStat("Gaps", gaps.toString(), Modifier.weight(1f)) + GapStat("Usable", compactDuration(usableMinutes), Modifier.weight(1f), divider = true) + GapStat("Days", days.toString(), Modifier.weight(1f), divider = true) + } + } +} + +@Composable +private fun GapStat(label: String, value: String, modifier: Modifier, divider: Boolean = false) { + Row(modifier = modifier) { + if (divider) androidx.compose.material3.VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 12.dp)) { + WebEyebrow(label, accent = false) + Text(value, modifier = Modifier.padding(top = 4.dp), fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + } + } +} + +@Composable +private fun DayGapSection(weekday: DayOfWeek, gaps: List) { + val selected = gaps.first() + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(weekday.displayName(), fontSize = 17.sp, fontWeight = FontWeight.Medium) + Text( + "${gaps.size} ${if (gaps.size == 1) "gap" else "gaps"}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + ) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.08f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.55f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 13.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column { Text( - "GAP PLAN", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + "${formatTime(selected.from.endTime)}–${formatTime(selected.to.startTime)}", + fontSize = 12.sp, fontWeight = FontWeight.Bold, ) - Text("Make the time between classes count", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text( - "Import your ACORN calendar first. Gapwise will find usable gaps between your classes across your timetable.", + recommendationTitle(selected), + modifier = Modifier.padding(top = 4.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${compactDuration(selected.usableMinutes)} usable", + modifier = Modifier.padding(top = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, ) - Button(onClick = onImport) { Text("Import ACORN calendar") } } + Text(compactDuration(selected.minutes), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) } } + + RecommendationInspector(selected) } - return } +} - val terms = remember(meetings) { meetings.map { it.term }.distinct() } - var selectedTerm by remember(meetings) { - mutableStateOf(terms.firstOrNull { it == currentTerm() } ?: terms.first()) - } - val termMeetings = meetings.filter { it.term == selectedTerm } - val gaps = remember(termMeetings) { calculateGaps(termMeetings) } +@Composable +private fun RecommendationInspector(gap: GapPlanItem) { + val accent = MaterialTheme.colorScheme.tertiary + val lunch = recommendationTitle(gap).startsWith("Lunch") + val protected = if (lunch) 30 else minOf(60, gap.usableMinutes) + val remaining = (gap.usableMinutes - protected).coerceAtLeast(0) - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - item { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + Column(modifier = Modifier.padding(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(11.dp), verticalAlignment = Alignment.Top) { + Surface( + shape = RoundedCornerShape(8.dp), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)), ) { + Icon( + imageVector = if (lunch) Icons.Outlined.Restaurant else Icons.Outlined.Route, + contentDescription = null, + modifier = Modifier.padding(9.dp), + tint = accent, + ) + } + Column(modifier = Modifier.weight(1f)) { + WebEyebrow("Recommended", accent = false) Text( - "GAP PLAN", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, + recommendationTitle(gap), + modifier = Modifier.padding(top = 5.dp), + fontSize = 19.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, ) - Text("Your time between classes", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text( - if (gaps.isEmpty()) "No positive class-to-class gaps in ${selectedTerm.label}." else "${gaps.size} usable ${if (gaps.size == 1) "gap" else "gaps"} found in ${selectedTerm.label}.", + text = if (lunch) { + "30 min protected for eating, with ${compactDuration(remaining)} left for studying or resting." + } else { + "${compactDuration(protected)} focused work fits, with ${compactDuration(remaining)} left to reset before class." + }, + modifier = Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + lineHeight = 19.sp, ) - if (terms.size > 1) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(terms) { term -> - FilterChip( - selected = term == selectedTerm, - onClick = { selectedTerm = term }, - label = { Text(term.label) }, - ) - } - } - } } } - } - items(gaps, key = { "${it.from.id}-${it.to.id}" }) { gap -> - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + .height(5.dp), + shape = RoundedCornerShape(99.dp), + color = MaterialTheme.colorScheme.surfaceVariant, ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - gap.from.weekday.name.lowercase().replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, + Row { + androidx.compose.foundation.layout.Spacer( + modifier = Modifier + .weight(gap.usableMinutes.coerceAtLeast(1).toFloat()) + .fillMaxHeight() + .clip(RoundedCornerShape(99.dp)) + .background(accent), ) - Text( - durationLabel(gap.minutes), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - "${gap.from.courseCode} → ${gap.to.courseCode}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.weight((gap.minutes - gap.usableMinutes).coerceAtLeast(1).toFloat()), ) + } + } + + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Text("${compactDuration(gap.usableMinutes)} usable", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + Text("7 min buffer", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + Text("3 min travel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + } + + Row( + modifier = Modifier.padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + ChoiceChip(if (lunch) "Eat" else "Study", selected = true) + ChoiceChip("Flexible", selected = false) + ChoiceChip(if (lunch) "Study" else "Rest", selected = false) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 13.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Outlined.Route, contentDescription = null, tint = accent) + Text("Transition", modifier = Modifier.padding(start = 7.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } Text( - "${formatTime(gap.from.endTime)} – ${formatTime(gap.to.startTime)}", + "${gap.from.locationLabel} → ${gap.to.courseCode} · ${gap.to.locationLabel}", + modifier = Modifier.padding(top = 8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 17.sp, ) Text( - "${gap.from.locationLabel} → ${gap.to.locationLabel}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "Leave by ${formatTime(gap.to.startTime - 10)}", + modifier = Modifier.padding(top = 5.dp), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, ) } } @@ -158,26 +353,72 @@ fun GapPlanScreen( } } +@Composable +private fun ChoiceChip(label: String, selected: Boolean) { + Surface( + shape = RoundedCornerShape(6.dp), + color = if (selected) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.10f) else Color.Transparent, + border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.45f) else MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun EmptyGaps(onImport: () -> Unit) { + LazyColumn(contentPadding = PaddingValues(16.dp)) { + item { + WebSurface(padding = PaddingValues(20.dp)) { + WebEyebrow("Between classes", accent = false) + Text("Gap plan", modifier = Modifier.padding(top = 6.dp), fontSize = 24.sp, fontWeight = FontWeight.Medium) + Text( + "Import your ACORN calendar first. Gapwise will find usable gaps between your classes.", + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 21.sp, + ) + Button(onClick = onImport, modifier = Modifier.padding(top = 14.dp), shape = RoundedCornerShape(9.dp)) { + Text("Import ACORN calendar") + } + } + } + } +} + private fun calculateGaps(meetings: List): List = meetings .filterNot { it.isAssessmentWindow } .groupBy { it.weekday } .toSortedMap(compareBy(DayOfWeek::getValue)) .flatMap { (_, dayMeetings) -> - dayMeetings - .sortedBy { it.startTime } - .zipWithNext() - .mapNotNull { (from, to) -> - val minutes = to.startTime - from.endTime - if (minutes > 0) GapPlanItem(from = from, to = to, minutes = minutes) else null - } + dayMeetings.sortedBy { it.startTime }.zipWithNext().mapNotNull { (from, to) -> + val minutes = to.startTime - from.endTime + if (minutes > 0) GapPlanItem(from, to, minutes) else null + } } -private fun durationLabel(minutes: Int): String = when { - minutes < 60 -> "$minutes min" +private fun recommendationTitle(gap: GapPlanItem): String = + if (gap.from.endTime <= 13 * 60 && gap.to.startTime >= 11 * 60 + 30 && gap.usableMinutes >= 30) { + "Lunch fits comfortably" + } else { + "Study block fits comfortably" + } + +private fun compactDuration(minutes: Int): String = when { + minutes <= 0 -> "0m" + minutes < 60 -> "${minutes}m" minutes % 60 == 0 -> "${minutes / 60} hr" - else -> "${minutes / 60} hr ${minutes % 60} min" + else -> "${minutes / 60} hr ${minutes % 60}m" } +private fun DayOfWeek.displayName(): String = name.lowercase().replaceFirstChar(Char::titlecase) + private fun currentTerm(): Term = when (LocalDate.now().monthValue) { in 1..4 -> Term.WINTER in 5..8 -> Term.SUMMER From 3ba6b5975eb978a50c86bd4167aecf6aea0c5c17 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:20:31 -0400 Subject: [PATCH 06/29] Match Map screen to web mobile route layout --- .../gapwise/android/feature/map/MapScreen.kt | 467 ++++++++++++------ 1 file changed, 325 insertions(+), 142 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index 4795476..f31a940 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -1,18 +1,29 @@ package ca.gapwise.android.feature.map +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,20 +37,27 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import ca.gapwise.android.R +import ca.gapwise.android.core.designsystem.WebControlShape +import ca.gapwise.android.core.designsystem.WebEyebrow +import ca.gapwise.android.core.designsystem.WebSegmentButton +import ca.gapwise.android.core.designsystem.WebSurface import ca.gapwise.android.core.model.Campus import ca.gapwise.android.core.model.LocationType import ca.gapwise.android.core.model.Meeting +import ca.gapwise.android.core.model.Term import org.maplibre.android.MapLibre import org.maplibre.android.annotations.MarkerOptions import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapView +import java.time.DayOfWeek +import java.time.LocalDate private const val UTM_LAT = 43.55105 private const val UTM_LON = -79.66475 @@ -48,6 +66,14 @@ private const val BUILDING_ZOOM = 17.4 private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron" private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark" +private val BaseDays = listOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, +) + private data class BuildingPoint( val code: String, val name: String, @@ -74,6 +100,142 @@ private val BuildingPoints = listOf( fun MapScreen( meetings: List, darkTheme: Boolean, +) { + val availableTerms = remember(meetings) { meetings.map { it.term }.distinct() } + var selectedTerm by remember(meetings) { + mutableStateOf(availableTerms.firstOrNull { it == currentTerm() } ?: availableTerms.firstOrNull() ?: Term.FALL) + } + val termMeetings = meetings.filter { it.term == selectedTerm && !it.isAssessmentWindow } + val routeDays = remember(termMeetings) { + BaseDays + listOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).filter { weekend -> + termMeetings.any { it.weekday == weekend } + } + } + var weekday by remember(selectedTerm, termMeetings) { + mutableStateOf( + LocalDate.now().dayOfWeek.takeIf { current -> termMeetings.any { it.weekday == current } } + ?: routeDays.firstOrNull { day -> termMeetings.any { it.weekday == day } } + ?: DayOfWeek.MONDAY, + ) + } + val dayMeetings = termMeetings.filter { it.weekday == weekday }.sortedBy { it.startTime } + var optionsOpen by remember { mutableStateOf(false) } + + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + WebSurface(padding = PaddingValues(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + WebEyebrow("Campus route") + Text( + text = weekday.displayName(), + modifier = Modifier.padding(top = 4.dp), + fontSize = 20.sp, + lineHeight = 23.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.6).sp, + ) + Text( + text = "${dayMeetings.size} ${if (dayMeetings.size == 1) "class" else "classes"} · time-labelled map", + modifier = Modifier.padding(top = 2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 14.sp, + ) + } + OutlinedButton( + onClick = { optionsOpen = true }, + shape = RoundedCornerShape(8.dp), + ) { + Icon(imageVector = Icons.Outlined.Tune, contentDescription = null) + Text("Options", modifier = Modifier.padding(start = 6.dp), fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + } + } + + if (availableTerms.size > 1) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + shape = WebControlShape, + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row(modifier = Modifier.padding(3.dp)) { + availableTerms.forEach { term -> + WebSegmentButton( + label = term.label, + selected = selectedTerm == term, + onClick = { selectedTerm = term }, + modifier = Modifier.weight(1f), + minHeight = 38.dp, + ) + } + } + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp), + shape = WebControlShape, + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row(modifier = Modifier.padding(3.dp)) { + routeDays.forEach { day -> + WebSegmentButton( + label = day.shortName(), + selected = weekday == day, + onClick = { weekday = day }, + modifier = Modifier.weight(1f), + minHeight = 42.dp, + ) + } + } + } + } + } + + item { + CampusMapCard( + meetings = dayMeetings, + darkTheme = darkTheme, + ) + } + } + + if (optionsOpen) { + ModalBottomSheet( + onDismissRequest = { optionsOpen = false }, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Route options", fontSize = 19.sp, fontWeight = FontWeight.SemiBold) + Text( + "Walking speed, transition buffer, indoor preference, and step-free routing are available from More.", + modifier = Modifier.padding(top = 7.dp, bottom = 24.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + lineHeight = 20.sp, + ) + } + } + } +} + +@Composable +private fun CampusMapCard( + meetings: List, + darkTheme: Boolean, ) { val destinations = remember(meetings) { meetings @@ -86,154 +248,145 @@ fun MapScreen( val results = remember(query) { val normalized = query.trim().lowercase() if (normalized.isBlank()) emptyList() - else BuildingPoints.values - .filter { point -> - point.code.lowercase().contains(normalized) || - point.name.lowercase().contains(normalized) - } - .take(8) + else BuildingPoints.values.filter { point -> + point.code.lowercase().contains(normalized) || point.name.lowercase().contains(normalized) + }.take(8) } val selected = selectedCode?.let(BuildingPoints::get) - val mapKey = destinations.joinToString("|") { it.buildingCode.orEmpty() } + val mapKey = destinations.joinToString("|") { "${it.id}:${it.buildingCode}:${it.startTime}" } - Box( + Surface( modifier = Modifier - .fillMaxSize() - .padding(12.dp) - .clip(RoundedCornerShape(16.dp)), + .fillMaxWidth() + .height(390.dp), + shape = RoundedCornerShape(11.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, ) { - key(darkTheme, mapKey) { - UtmMap( - destinations = destinations, - darkTheme = darkTheme, - focusedBuilding = selected, - onBuildingSelected = { selectedCode = it }, - modifier = Modifier.fillMaxSize(), - ) - } + Box(modifier = Modifier.fillMaxSize()) { + key(darkTheme, mapKey) { + UtmMap( + destinations = destinations, + darkTheme = darkTheme, + focusedBuilding = selected, + onBuildingSelected = { selectedCode = it }, + modifier = Modifier.fillMaxSize(), + ) + } - Column( - modifier = Modifier - .align(Alignment.TopStart) - .fillMaxWidth() - .padding(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .padding(10.dp), ) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - placeholder = { Text("Search MN, Deerfield…") }, - leadingIcon = { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( - painter = painterResource(R.drawable.ic_lucide_search), + imageVector = Icons.Outlined.Search, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), ) - }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.weight(1f), - ) - OutlinedButton(onClick = { selectedCode = null }) { - Text("Campus") + BasicTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiary), + modifier = Modifier + .weight(1f) + .padding(start = 9.dp), + decorationBox = { inner -> + if (query.isBlank()) { + Text("Search MN, Deerfield, Kaneff…", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + inner() + }, + ) + } } - } - if (query.isNotBlank()) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), - tonalElevation = 4.dp, - ) { - Column(modifier = Modifier.padding(vertical = 5.dp)) { - if (results.isEmpty()) { - Text( - "No mapped UTM building matches that search.", - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - results.forEach { building -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - selectedCode = building.code - query = "" - } - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Surface( - shape = RoundedCornerShape(7.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + + if (query.isNotBlank()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + if (results.isEmpty()) { + Text( + "No mapped UTM building matches that search.", + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } else { + results.forEach { building -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + selectedCode = building.code + query = "" + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - building.code, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) + Text(building.code, color = MaterialTheme.colorScheme.tertiary, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text(building.name, modifier = Modifier.padding(start = 10.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } - Text( - building.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) } } } } } } - } - if (selected != null) { - val classAtBuilding = destinations.firstOrNull { it.buildingCode == selected.code } - Surface( + Column( modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp) - .fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), - tonalElevation = 5.dp, + .align(Alignment.CenterEnd) + .padding(end = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + MapControl(Icons.Outlined.Fullscreen) { } + MapControl(Icons.Outlined.MyLocation) { selectedCode = null } + } + + if (selected != null) { + val classAtBuilding = destinations.firstOrNull { it.buildingCode == selected.code } + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(10.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - Text( - "${selected.code} · UTM", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - Text( - selected.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - if (classAtBuilding != null) { - Text( - "${classAtBuilding.courseCode} · ${classAtBuilding.locationLabel}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - "Mapped UTM building", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(modifier = Modifier.padding(12.dp)) { + WebEyebrow("${selected.code} · UTM") + Text(selected.name, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + classAtBuilding?.let { meeting -> + Text( + "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + ) + } } } } @@ -241,6 +394,23 @@ fun MapScreen( } } +@Composable +private fun MapControl(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) { + Surface( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(9.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(17.dp)) + } + } +} + @Composable private fun UtmMap( destinations: List, @@ -260,24 +430,26 @@ private fun UtmMap( getMapAsync { map -> map.uiSettings.setLogoEnabled(false) map.uiSettings.setAttributionEnabled(true) - map.uiSettings.setCompassEnabled(true) + map.uiSettings.setCompassEnabled(false) map.uiSettings.setAllGesturesEnabled(true) map.cameraPosition = CameraPosition.Builder() .target(LatLng(UTM_LAT, UTM_LON)) .zoom(CAMPUS_ZOOM) .build() map.setOnMarkerClickListener { marker -> - marker.snippet - ?.takeIf(BuildingPoints::containsKey) - ?.let(onBuildingSelected) + marker.snippet?.takeIf(BuildingPoints::containsKey)?.let(onBuildingSelected) false } map.setStyle(styleUrl) { + val scheduledCodes = destinations.mapNotNull { it.buildingCode }.toSet() BuildingPoints.values.forEach { point -> + val classTimes = destinations + .filter { it.buildingCode == point.code } + .joinToString(" · ") { formatTimeLabel(it.startTime) } map.addMarker( MarkerOptions() .position(LatLng(point.latitude, point.longitude)) - .title("${point.code} · ${point.name}") + .title(if (classTimes.isBlank()) "${point.code} · ${point.name}" else classTimes) .snippet(point.code), ) } @@ -289,15 +461,9 @@ private fun UtmMap( LaunchedEffect(focusedBuilding?.code, mapView) { mapView.getMapAsync { map -> val focus = focusedBuilding - val target = if (focus == null) { - LatLng(UTM_LAT, UTM_LON) to CAMPUS_ZOOM - } else { - LatLng(focus.latitude, focus.longitude) to BUILDING_ZOOM - } - map.animateCamera( - CameraUpdateFactory.newLatLngZoom(target.first, target.second), - 450, - ) + val target = if (focus == null) LatLng(UTM_LAT, UTM_LON) to CAMPUS_ZOOM + else LatLng(focus.latitude, focus.longitude) to BUILDING_ZOOM + map.animateCamera(CameraUpdateFactory.newLatLngZoom(target.first, target.second), 450) } } @@ -311,3 +477,20 @@ private fun UtmMap( AndroidView(factory = { mapView }, modifier = modifier) } + +private fun formatTimeLabel(minutes: Int): String { + val h24 = minutes / 60 + val minute = minutes % 60 + val suffix = if (h24 >= 12) "PM" else "AM" + val hour = when (val value = h24 % 12) { 0 -> 12 else -> value } + return "%d:%02d %s".format(hour, minute, suffix) +} + +private fun DayOfWeek.displayName(): String = name.lowercase().replaceFirstChar(Char::titlecase) +private fun DayOfWeek.shortName(): String = name.take(3).lowercase().replaceFirstChar(Char::titlecase) + +private fun currentTerm(): Term = when (LocalDate.now().monthValue) { + in 1..4 -> Term.WINTER + in 5..8 -> Term.SUMMER + else -> Term.FALL +} From 20d6b43fa53f629f79861c0ab0e11a819582aa76 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:21:05 -0400 Subject: [PATCH 07/29] Add compact More drawer matching web mobile --- .../feature/settings/CompactMoreSheet.kt | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt diff --git a/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt b/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt new file mode 100644 index 0000000..17c3a8d --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt @@ -0,0 +1,159 @@ +package ca.gapwise.android.feature.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ca.gapwise.android.core.persistence.AppThemeMode + +@Composable +fun CompactMoreSheetContent( + themeMode: AppThemeMode, + accountLabel: String, + canRemoveTimetable: Boolean, + onOpenAcademicPreferences: () -> Unit, + onToggleTheme: () -> Unit, + onOpenArrivalPreferences: () -> Unit, + onOpenAccount: () -> Unit, + onUpdateTimetable: () -> Unit, + onRemoveTimetable: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = "PREFERENCES", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + ) + LazyRow( + modifier = Modifier.padding(top = 9.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + item { CompactPreferenceChip("Academic work", onOpenAcademicPreferences) } + item { + Surface( + onClick = onToggleTheme, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Icon( + imageVector = if (themeMode == AppThemeMode.DARK) Icons.Outlined.DarkMode else Icons.Outlined.LightMode, + contentDescription = if (themeMode == AppThemeMode.DARK) "Use light theme" else "Use dark theme", + modifier = Modifier.padding(9.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + item { CompactPreferenceChip("Arrival", onOpenArrivalPreferences) } + item { + Surface( + onClick = onOpenAccount, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.Outlined.PersonOutline, contentDescription = null) + Text( + accountLabel, + modifier = Modifier.padding(start = 7.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(top = 17.dp, bottom = 15.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Text( + text = "TIMETABLE", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + ) + Button( + onClick = onUpdateTimetable, + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp), + shape = RoundedCornerShape(9.dp), + ) { + Icon(imageVector = Icons.Outlined.Upload, contentDescription = null) + Text("Update timetable", modifier = Modifier.padding(start = 7.dp), fontWeight = FontWeight.SemiBold) + } + if (canRemoveTimetable) { + OutlinedButton( + onClick = onRemoveTimetable, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp), + shape = RoundedCornerShape(9.dp), + ) { + Text("Remove timetable", color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun CompactPreferenceChip(label: String, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 11.dp, vertical = 9.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} From e11b434e16984dd0cd26180852204f4591902678 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:21:54 -0400 Subject: [PATCH 08/29] Match Android shell and More drawer to mobile web --- .../gapwise/android/navigation/GapwiseApp.kt | 132 ++++++++++++++---- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt b/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt index 08b82aa..1fab40d 100644 --- a/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt +++ b/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt @@ -5,17 +5,22 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -33,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role @@ -58,6 +64,7 @@ import ca.gapwise.android.data.sync.EncryptedCloudSync import ca.gapwise.android.data.timetable.IcsParser import ca.gapwise.android.feature.gapplan.GapPlanScreen import ca.gapwise.android.feature.map.MapScreen +import ca.gapwise.android.feature.settings.CompactMoreSheetContent import ca.gapwise.android.feature.settings.SettingsScreen import ca.gapwise.android.feature.timetable.TimetableScreen import ca.gapwise.android.feature.today.TodayScreen @@ -98,6 +105,7 @@ fun GapwiseApp( var syncBusy by remember { mutableStateOf(false) } var syncStatus by remember { mutableStateOf(null) } var moreOpen by remember { mutableStateOf(false) } + var settingsOpen by remember { mutableStateOf(false) } fun saveTimetable(value: List) { meetings = value @@ -189,7 +197,17 @@ fun GapwiseApp( modifier = Modifier.padding(paddingValues), ) { composable(Destination.Today.route) { - TodayScreen(meetings = meetings, importStatus = importStatus, onImport = startImport) + TodayScreen( + meetings = meetings, + importStatus = importStatus, + onImport = startImport, + onDayRoute = { + navController.navigate(Destination.Map.route) { + launchSingleTop = true + restoreState = true + } + }, + ) } composable(Destination.Timetable.route) { TimetableScreen(meetings = meetings, importStatus = importStatus, onImport = startImport) @@ -207,10 +225,61 @@ fun GapwiseApp( ModalBottomSheet( onDismissRequest = { moreOpen = false }, containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), ) { Text( text = "More", - style = MaterialTheme.typography.titleLarge, + fontSize = 19.sp, + lineHeight = 23.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 14.dp), + ) + CompactMoreSheetContent( + themeMode = themeMode, + accountLabel = account?.email?.substringBefore('@')?.takeIf { it.isNotBlank() } ?: "Account", + canRemoveTimetable = meetings.isNotEmpty(), + onOpenAcademicPreferences = { + moreOpen = false + settingsOpen = true + }, + onToggleTheme = { + onThemeModeChange(if (themeMode == AppThemeMode.DARK) AppThemeMode.LIGHT else AppThemeMode.DARK) + }, + onOpenArrivalPreferences = { + moreOpen = false + settingsOpen = true + }, + onOpenAccount = { + moreOpen = false + settingsOpen = true + }, + onUpdateTimetable = { + moreOpen = false + startImport() + }, + onRemoveTimetable = { + saveTimetable(emptyList()) + importStatus = "Timetable removed from this device." + pushIfEnabled(emptyList()) + moreOpen = false + }, + ) + } + } + + if (settingsOpen) { + ModalBottomSheet( + onDismissRequest = { settingsOpen = false }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + ) { + Text( + text = "Preferences", + fontSize = 19.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) @@ -228,7 +297,7 @@ fun GapwiseApp( syncStatus = syncStatus, onThemeModeChange = onThemeModeChange, onImport = { - moreOpen = false + settingsOpen = false startImport() }, onClearTimetable = { @@ -377,6 +446,7 @@ private fun GapwiseTopBar(pageLabel: String) { text = "Gapwise", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.5).sp, ) } Text( @@ -432,29 +502,43 @@ private fun MobileNavItem( onClick: () -> Unit, modifier: Modifier, ) { - val color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - Column( + val accent = MaterialTheme.colorScheme.tertiary + val color = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant + Box( modifier = modifier .heightIn(min = 60.dp) - .clickable(role = Role.Tab, onClick = onClick) - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .background(if (selected) accent.copy(alpha = 0.07f) else Color.Transparent, RoundedCornerShape(7.dp)) + .clickable(role = Role.Tab, onClick = onClick), ) { - Icon( - painter = painterResource(iconRes), - contentDescription = label, - modifier = Modifier.size(18.dp), - tint = color, - ) - Text( - text = label, - fontSize = 10.sp, - lineHeight = 12.sp, - color = color, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 4.dp), - maxLines = 1, - ) + if (selected) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .width(26.dp) + .height(2.dp) + .background(accent, RoundedCornerShape(99.dp)), + ) + } + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = label, + modifier = Modifier.size(18.dp), + tint = color, + ) + Text( + text = label, + fontSize = 10.sp, + lineHeight = 12.sp, + color = color, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), + maxLines = 1, + ) + } } } From f28a39ee9ac1289c08f4f6699870a5535a93585d Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:22:58 -0400 Subject: [PATCH 09/29] Fix Gap plan Compose imports and parity polish --- .../ca/gapwise/android/feature/gapplan/GapPlanScreen.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt b/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt index b1fab94..4ac0fc2 100644 --- a/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/gapplan/GapPlanScreen.kt @@ -1,21 +1,22 @@ package ca.gapwise.android.feature.gapplan import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PeopleOutline import androidx.compose.material.icons.outlined.Restaurant import androidx.compose.material.icons.outlined.Route import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.outlined.PeopleOutline import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -77,7 +78,7 @@ fun GapPlanScreen( fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Medium, - letterSpacing = (-0.6).sp, + letterSpacing = (-0.6f).sp, ) Text( text = "See what actually fits after walking time, setup, and the buffer before your next class.", From 93a83a6069a37ee6f7ac195425d497e77b6f6f88 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:23:17 -0400 Subject: [PATCH 10/29] Add temporary parity branch build check --- .github/workflows/parity-build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/parity-build.yml diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml new file mode 100644 index 0000000..591df8e --- /dev/null +++ b/.github/workflows/parity-build.yml @@ -0,0 +1,19 @@ +name: Android parity build + +on: + push: + branches: [web-mobile-ui-parity] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - run: chmod +x gradlew + - run: ./gradlew :app:assembleDebug --stacktrace From 1bc26d06712d7dc490a8a167d87e21680cb0c4dc Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:23:52 -0400 Subject: [PATCH 11/29] Run parity build on pull requests --- .github/workflows/parity-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml index 591df8e..5c01aeb 100644 --- a/.github/workflows/parity-build.yml +++ b/.github/workflows/parity-build.yml @@ -3,6 +3,8 @@ name: Android parity build on: push: branches: [web-mobile-ui-parity] + pull_request: + branches: [main] workflow_dispatch: jobs: From 0f934639bec91bb7d83919662eb7f915a5a6c56e Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:24:58 -0400 Subject: [PATCH 12/29] Build with provisioned Gradle distribution --- .github/workflows/parity-build.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml index 5c01aeb..075785b 100644 --- a/.github/workflows/parity-build.yml +++ b/.github/workflows/parity-build.yml @@ -17,5 +17,6 @@ jobs: distribution: temurin java-version: '17' - uses: gradle/actions/setup-gradle@v4 - - run: chmod +x gradlew - - run: ./gradlew :app:assembleDebug --stacktrace + with: + gradle-version: current + - run: gradle :app:assembleDebug --stacktrace From 0d1dfd4ad13cc75171f69387a68e81fc1833e3eb Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:29:31 -0400 Subject: [PATCH 13/29] Expose parity build diagnostics on PR --- .github/workflows/parity-build.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml index 075785b..6e5cad4 100644 --- a/.github/workflows/parity-build.yml +++ b/.github/workflows/parity-build.yml @@ -7,6 +7,10 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + pull-requests: write + jobs: build: runs-on: ubuntu-latest @@ -19,4 +23,24 @@ jobs: - uses: gradle/actions/setup-gradle@v4 with: gradle-version: current - - run: gradle :app:assembleDebug --stacktrace + - name: Build debug APK + shell: bash + run: | + set -o pipefail + gradle :app:assembleDebug --stacktrace 2>&1 | tee build.log + - name: Report build diagnostics + if: ${{ failure() && github.event_name == 'pull_request' }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + { + echo '' + echo 'Android parity build failed. Tail of Gradle output:' + echo + echo '```text' + tail -n 160 build.log + echo '```' + } > diagnostics.md + gh pr comment "$PR_NUMBER" --body-file diagnostics.md From d24963c5b4ede09bb99a40cc140c5a48099906b4 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:33:25 -0400 Subject: [PATCH 14/29] Capture Kotlin compiler diagnostics in parity build --- .github/workflows/parity-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml index 6e5cad4..9ecf08b 100644 --- a/.github/workflows/parity-build.yml +++ b/.github/workflows/parity-build.yml @@ -37,10 +37,10 @@ jobs: run: | { echo '' - echo 'Android parity build failed. Tail of Gradle output:' + echo 'Android parity build failed. Kotlin/Gradle diagnostics:' echo echo '```text' - tail -n 160 build.log + grep -E '(^e:| error:|Unresolved reference|Cannot infer|No parameter with name|Type mismatch|Argument type mismatch|Compilation error)' build.log | tail -n 220 || true echo '```' } > diagnostics.md gh pr comment "$PR_NUMBER" --body-file diagnostics.md From ccba35b1c5e5d9797fa6ebba8a51baacb7f6ea69 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:38:07 -0400 Subject: [PATCH 15/29] Opt in to Material3 route options sheet and polish controls --- .../java/ca/gapwise/android/feature/map/MapScreen.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index f31a940..99d2cfb 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -96,6 +97,7 @@ private val BuildingPoints = listOf( BuildingPoint("DW", "Erindale Studio Theatre", 43.5499078, -79.6661378), ).associateBy { it.code } +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun MapScreen( meetings: List, @@ -140,7 +142,7 @@ fun MapScreen( fontSize = 20.sp, lineHeight = 23.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.6).sp, + letterSpacing = (-0.6f).sp, ) Text( text = "${dayMeetings.size} ${if (dayMeetings.size == 1) "class" else "classes"} · time-labelled map", @@ -152,9 +154,14 @@ fun MapScreen( } OutlinedButton( onClick = { optionsOpen = true }, + modifier = Modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp), ) { - Icon(imageVector = Icons.Outlined.Tune, contentDescription = null) + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) Text("Options", modifier = Modifier.padding(start = 6.dp), fontSize = 11.sp, fontWeight = FontWeight.SemiBold) } } @@ -441,7 +448,6 @@ private fun UtmMap( false } map.setStyle(styleUrl) { - val scheduledCodes = destinations.mapNotNull { it.buildingCode }.toSet() BuildingPoints.values.forEach { point -> val classTimes = destinations .filter { it.buildingCode == point.code } From 3350656b20883c0bef713331670bd3d3bfe51547 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:38:19 -0400 Subject: [PATCH 16/29] Use Kotlin compile task for fast parity validation --- .github/workflows/parity-build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml index 9ecf08b..ffe8976 100644 --- a/.github/workflows/parity-build.yml +++ b/.github/workflows/parity-build.yml @@ -23,11 +23,11 @@ jobs: - uses: gradle/actions/setup-gradle@v4 with: gradle-version: current - - name: Build debug APK + - name: Compile Android app shell: bash run: | set -o pipefail - gradle :app:assembleDebug --stacktrace 2>&1 | tee build.log + gradle :app:compileDebugKotlin --stacktrace 2>&1 | tee build.log - name: Report build diagnostics if: ${{ failure() && github.event_name == 'pull_request' }} env: @@ -37,7 +37,7 @@ jobs: run: | { echo '' - echo 'Android parity build failed. Kotlin/Gradle diagnostics:' + echo 'Android parity compile failed. Kotlin/Gradle diagnostics:' echo echo '```text' grep -E '(^e:| error:|Unresolved reference|Cannot infer|No parameter with name|Type mismatch|Argument type mismatch|Compilation error)' build.log | tail -n 220 || true From b7f669410b7a2b958f1835a858dd14b51a351c55 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:48:14 -0400 Subject: [PATCH 17/29] Match timetable cards, activity colors and gap rows to mobile web --- .../feature/timetable/TimetableScreen.kt | 262 +++++++++++------- 1 file changed, 165 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt b/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt index 458ae20..3ab2058 100644 --- a/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/timetable/TimetableScreen.kt @@ -1,25 +1,20 @@ package ca.gapwise.android.feature.timetable import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccessTime import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.Place +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -39,6 +35,7 @@ import ca.gapwise.android.core.designsystem.WebControlShape import ca.gapwise.android.core.designsystem.WebEyebrow import ca.gapwise.android.core.designsystem.WebSegmentButton import ca.gapwise.android.core.designsystem.WebSurface +import ca.gapwise.android.core.model.ActivityType import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term import ca.gapwise.android.core.model.formatTime @@ -53,11 +50,21 @@ private val BaseDays = listOf( DayOfWeek.FRIDAY, ) +private data class TimetableGap( + val previous: Meeting, + val next: Meeting, +) { + val startTime: Int get() = previous.endTime + val endTime: Int get() = next.startTime + val durationMinutes: Int get() = endTime - startTime +} + @Composable fun TimetableScreen( meetings: List, importStatus: String?, onImport: () -> Unit, + onOpenGapPlan: () -> Unit = {}, ) { if (meetings.isEmpty()) { EmptyTimetable(importStatus = importStatus, onImport = onImport) @@ -84,6 +91,14 @@ fun TimetableScreen( val dayMeetings = termMeetings.filter { it.weekday == selectedDay }.sortedBy { it.startTime } val dayClasses = dayMeetings.filterNot { it.isAssessmentWindow } val reservedCount = dayMeetings.size - dayClasses.size + val gapAfterMeeting = remember(dayClasses) { + dayClasses + .zipWithNext() + .mapNotNull { (previous, next) -> + if (next.startTime > previous.endTime) TimetableGap(previous, next) else null + } + .associateBy { it.previous.id } + } LazyColumn( contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 20.dp), @@ -207,6 +222,9 @@ fun TimetableScreen( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { dayMeetings.forEach { meeting -> MeetingCard(meeting) + gapAfterMeeting[meeting.id]?.let { gap -> + GapCard(gap = gap, onClick = onOpenGapPlan) + } } } } @@ -217,124 +235,168 @@ fun TimetableScreen( @Composable private fun MeetingCard(meeting: Meeting) { - val accent = MaterialTheme.colorScheme.tertiary + val accent = activityAccent(meeting) val location = locationPresentation(meeting) Surface( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 0.dp, ) { - Row { - Box( - modifier = Modifier - .width(2.dp) - .fillMaxHeight() - .background(accent), - ) - Column( - modifier = Modifier - .weight(1f) - .padding(13.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = meeting.courseCode, + color = accent, + fontSize = 15.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Surface( + modifier = Modifier.padding(start = 8.dp), + shape = RoundedCornerShape(5.dp), + color = accent.copy(alpha = 0.11f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.32f)), + ) { Text( - text = meeting.courseCode, - fontSize = 15.sp, - lineHeight = 18.sp, + text = meeting.activityLabel, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), + color = accent, + fontSize = 9.5.sp, + lineHeight = 10.sp, fontWeight = FontWeight.Bold, - maxLines = 1, - ) - Surface( - modifier = Modifier.padding(start = 8.dp), - shape = RoundedCornerShape(5.dp), - color = accent.copy(alpha = 0.11f), - border = BorderStroke(1.dp, accent.copy(alpha = 0.28f)), - ) { - Text( - text = meeting.activityLabel, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), - color = accent, - fontSize = 9.5.sp, - lineHeight = 10.sp, - fontWeight = FontWeight.Bold, - ) - } - Icon( - imageVector = Icons.Outlined.ChevronRight, - contentDescription = null, - modifier = Modifier.padding(start = 6.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Icon( + imageVector = Icons.Outlined.ChevronRight, + contentDescription = null, + modifier = Modifier.padding(start = 6.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Row( - modifier = Modifier.padding(top = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Outlined.AccessTime, - contentDescription = null, - tint = accent, - ) + Row( + modifier = Modifier.padding(top = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.AccessTime, + contentDescription = null, + tint = accent, + ) + Text( + text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Row( + modifier = Modifier.padding(top = 6.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = if (meeting.isAssessmentWindow) Icons.Outlined.AccessTime else Icons.Outlined.Place, + contentDescription = null, + tint = accent, + ) + Column { Text( - text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", + text = location.first, fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold, ) - } - - Row( - modifier = Modifier.padding(top = 6.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Outlined.Place, - contentDescription = null, - tint = accent, - ) - Column { + location.second?.let { floor -> Text( - text = location.first, - fontSize = 12.sp, - lineHeight = 15.sp, - fontWeight = FontWeight.SemiBold, + text = floor, + modifier = Modifier.padding(top = 2.dp), + color = if (meeting.isAssessmentWindow) accent.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Medium, ) - location.second?.let { floor -> - Text( - text = floor, - modifier = Modifier.padding(top = 2.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 11.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.Medium, - ) - } } } + } - if (meeting.courseName.isNotBlank()) { - Text( - text = meeting.courseName, - modifier = Modifier.padding(top = 9.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - lineHeight = 18.sp, - maxLines = 2, - ) - } + if (meeting.courseName.isNotBlank()) { + Text( + text = meeting.courseName, + modifier = Modifier.padding(top = 9.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + lineHeight = 18.sp, + maxLines = 2, + ) + } + } + } +} + +@Composable +private fun GapCard(gap: TimetableGap, onClick: () -> Unit) { + val accent = MaterialTheme.colorScheme.tertiary + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + color = accent.copy(alpha = 0.045f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.30f)), + shadowElevation = 0.dp, + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = null, + tint = accent, + ) + Text( + text = "${compactGapDuration(gap.durationMinutes)} gap", + modifier = Modifier.padding(start = 7.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "View gap plan", + modifier = Modifier + .weight(1f) + .padding(start = 12.dp), + color = accent, + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + ) } + Text( + text = "${formatTime(gap.startTime)} – ${formatTime(gap.endTime)}", + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + ) } } } +@Composable +private fun activityAccent(meeting: Meeting): Color = when { + meeting.isAssessmentWindow -> Color(0xFFF2C94C) + meeting.activityType == ActivityType.PRA -> Color(0xFFA78BFA) + meeting.activityType == ActivityType.TUT -> Color(0xFF2EC4B6) + meeting.activityType == ActivityType.LEC -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant +} + private fun locationPresentation(meeting: Meeting): Pair { if (meeting.isAssessmentWindow) return "Reserved assessment window" to "Only active when announced" val code = meeting.buildingCode @@ -408,6 +470,12 @@ private fun EmptyTimetable(importStatus: String?, onImport: () -> Unit) { } } +private fun compactGapDuration(minutes: Int): String = when { + minutes < 60 -> "${minutes}m" + minutes % 60 == 0 -> "${minutes / 60}h" + else -> "${minutes / 60}h ${minutes % 60}m" +} + private fun DayOfWeek.displayName(): String = name.lowercase().replaceFirstChar(Char::titlecase) private fun DayOfWeek.shortName(): String = name.take(3).lowercase().replaceFirstChar(Char::titlecase) From a2b46681a1a6b4809bc1d2c7ea8657c915f4756b Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 09:49:29 -0400 Subject: [PATCH 18/29] Bring native campus map interactions in line with mobile web --- .../gapwise/android/feature/map/MapScreen.kt | 373 +++++++++++++++--- 1 file changed, 319 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index 99d2cfb..7607bf1 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -1,5 +1,10 @@ package ca.gapwise.android.feature.map +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -17,7 +22,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Maximize import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Tune @@ -32,6 +38,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -52,18 +59,22 @@ import ca.gapwise.android.core.model.LocationType import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term import org.maplibre.android.MapLibre +import org.maplibre.android.annotations.IconFactory import org.maplibre.android.annotations.MarkerOptions +import org.maplibre.android.annotations.PolylineOptions import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapView import java.time.DayOfWeek import java.time.LocalDate +import kotlin.math.abs +import kotlin.math.max private const val UTM_LAT = 43.55105 private const val UTM_LON = -79.66475 private const val CAMPUS_ZOOM = 16.0 -private const val BUILDING_ZOOM = 17.4 +private const val BUILDING_ZOOM = 17.6 private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron" private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark" @@ -80,6 +91,8 @@ private data class BuildingPoint( val name: String, val latitude: Double, val longitude: Double, + val kind: String = "Academic", + val mappedEntrances: Int = 1, ) private val BuildingPoints = listOf( @@ -248,18 +261,25 @@ private fun CampusMapCard( meetings .filter { it.campus == Campus.UTM && it.locationType == LocationType.PHYSICAL } .filter { it.buildingCode != null && BuildingPoints.containsKey(it.buildingCode) } - .distinctBy { it.buildingCode } } var query by remember { mutableStateOf("") } var selectedCode by remember { mutableStateOf(null) } + var selectedMeetingId by remember { mutableStateOf(null) } + var fitRequest by remember { mutableIntStateOf(0) } + var campusRequest by remember { mutableIntStateOf(0) } val results = remember(query) { val normalized = query.trim().lowercase() + val compact = normalized.replace(" ", "") if (normalized.isBlank()) emptyList() else BuildingPoints.values.filter { point -> - point.code.lowercase().contains(normalized) || point.name.lowercase().contains(normalized) + val code = point.code.lowercase() + point.code.lowercase().contains(normalized) || + point.name.lowercase().contains(normalized) || + compact.startsWith(code) }.take(8) } val selected = selectedCode?.let(BuildingPoints::get) + val selectedMeeting = selectedMeetingId?.let { id -> destinations.firstOrNull { it.id == id } } val mapKey = destinations.joinToString("|") { "${it.id}:${it.buildingCode}:${it.startTime}" } Surface( @@ -277,7 +297,14 @@ private fun CampusMapCard( destinations = destinations, darkTheme = darkTheme, focusedBuilding = selected, - onBuildingSelected = { selectedCode = it }, + focusedMeeting = selectedMeeting, + fitRequest = fitRequest, + campusRequest = campusRequest, + onMeetingSelected = { id -> + selectedMeetingId = id + selectedCode = null + query = "" + }, modifier = Modifier.fillMaxSize(), ) } @@ -286,12 +313,12 @@ private fun CampusMapCard( modifier = Modifier .align(Alignment.TopStart) .fillMaxWidth() - .padding(10.dp), + .padding(start = 10.dp, top = 10.dp, end = 68.dp), ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(9.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Row( @@ -329,14 +356,14 @@ private fun CampusMapCard( .fillMaxWidth() .padding(top = 5.dp), shape = RoundedCornerShape(9.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.99f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - Column { + Column(modifier = Modifier.padding(5.dp)) { if (results.isEmpty()) { Text( "No mapped UTM building matches that search.", - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(9.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, ) @@ -345,15 +372,33 @@ private fun CampusMapCard( Row( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) .clickable { + selectedMeetingId = null selectedCode = building.code query = "" } - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 10.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(building.code, color = MaterialTheme.colorScheme.tertiary, fontSize = 11.sp, fontWeight = FontWeight.Bold) - Text(building.name, modifier = Modifier.padding(start = 10.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f), + ) { + Text( + building.code, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp), + color = MaterialTheme.colorScheme.tertiary, + fontSize = 10.5.sp, + fontWeight = FontWeight.Bold, + ) + } + Text( + building.name, + modifier = Modifier.padding(start = 10.dp), + fontSize = 12.dp.value.sp, + fontWeight = FontWeight.SemiBold, + ) } } } @@ -364,43 +409,145 @@ private fun CampusMapCard( Column( modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 10.dp), + .align(Alignment.TopEnd) + .padding(top = 84.dp, end = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - MapControl(Icons.Outlined.Fullscreen) { } - MapControl(Icons.Outlined.MyLocation) { selectedCode = null } + MapControl(Icons.Outlined.Maximize) { + selectedCode = null + selectedMeetingId = null + fitRequest += 1 + } + MapControl(Icons.Outlined.MyLocation) { + selectedCode = null + selectedMeetingId = null + campusRequest += 1 + } } - if (selected != null) { - val classAtBuilding = destinations.firstOrNull { it.buildingCode == selected.code } - Surface( + if (selectedMeeting != null) { + MeetingMapCard( + meeting = selectedMeeting, + onClose = { selectedMeetingId = null }, modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() .padding(10.dp), - shape = RoundedCornerShape(9.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Column(modifier = Modifier.padding(12.dp)) { - WebEyebrow("${selected.code} · UTM") - Text(selected.name, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - classAtBuilding?.let { meeting -> - Text( - "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", - modifier = Modifier.padding(top = 4.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 11.sp, - ) - } - } + ) + } else if (selected != null) { + BuildingMapCard( + building = selected, + onClose = { selectedCode = null }, + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(10.dp), + ) + } + } + } +} + +@Composable +private fun MeetingMapCard(meeting: Meeting, onClose: () -> Unit, modifier: Modifier) { + val point = meeting.buildingCode?.let(BuildingPoints::get) + Surface( + modifier = modifier, + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + WebEyebrow("${meeting.buildingCode ?: "UTM"} · UTM") + Text( + point?.name ?: meeting.locationLabel, + modifier = Modifier.padding(top = 4.dp), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + ) } + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Close class details", + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(7.dp)) + .clickable(onClick = onClose) + .padding(7.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } +@Composable +private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifier: Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + WebEyebrow("${building.code} · ${building.kind}") + Text( + building.name, + modifier = Modifier.padding(top = 5.dp), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Close building details", + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(7.dp)) + .clickable(onClick = onClose) + .padding(7.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${building.mappedEntrances} mapped ${if (building.mappedEntrances == 1) "entrance" else "entrances"}", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Partial coverage", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + ) + } + Text( + "${building.mappedEntrances} source-backed mapped ${if (building.mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped.", + modifier = Modifier.padding(top = 10.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 16.sp, + ) + } + } +} + @Composable private fun MapControl(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) { Surface( @@ -409,7 +556,7 @@ private fun MapControl(icon: androidx.compose.ui.graphics.vector.ImageVector, on .clip(RoundedCornerShape(9.dp)) .clickable(onClick = onClick), shape = RoundedCornerShape(9.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Box(contentAlignment = Alignment.Center) { @@ -423,7 +570,10 @@ private fun UtmMap( destinations: List, darkTheme: Boolean, focusedBuilding: BuildingPoint?, - onBuildingSelected: (String) -> Unit, + focusedMeeting: Meeting?, + fitRequest: Int, + campusRequest: Int, + onMeetingSelected: (String) -> Unit, modifier: Modifier, ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -444,32 +594,81 @@ private fun UtmMap( .zoom(CAMPUS_ZOOM) .build() map.setOnMarkerClickListener { marker -> - marker.snippet?.takeIf(BuildingPoints::containsKey)?.let(onBuildingSelected) - false + val snippet = marker.snippet.orEmpty() + if (snippet.startsWith("meeting:")) { + onMeetingSelected(snippet.removePrefix("meeting:")) + true + } else { + false + } } map.setStyle(styleUrl) { - BuildingPoints.values.forEach { point -> - val classTimes = destinations - .filter { it.buildingCode == point.code } - .joinToString(" · ") { formatTimeLabel(it.startTime) } - map.addMarker( - MarkerOptions() - .position(LatLng(point.latitude, point.longitude)) - .title(if (classTimes.isBlank()) "${point.code} · ${point.name}" else classTimes) - .snippet(point.code), + val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() + val routePoints = destinations.mapNotNull { meeting -> + meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> + LatLng(point.latitude, point.longitude) + } + } + if (routePoints.size > 1 && routePoints.distinct().size > 1) { + map.addPolyline( + PolylineOptions() + .addAll(routePoints) + .color(accent) + .width(4f), ) } + + val grouped = destinations.groupBy { it.buildingCode } + grouped.forEach { (code, buildingMeetings) -> + val point = code?.let(BuildingPoints::get) ?: return@forEach + buildingMeetings.sortedBy { it.startTime }.forEachIndexed { index, meeting -> + val latitudeOffset = index * 0.000075 + map.addMarker( + MarkerOptions() + .position(LatLng(point.latitude + latitudeOffset, point.longitude)) + .icon( + IconFactory.getInstance(context).fromBitmap( + createTimeMarkerBitmap( + label = formatTimeLabel(meeting.startTime), + darkTheme = darkTheme, + density = context.resources.displayMetrics.density, + ), + ), + ) + .snippet("meeting:${meeting.id}"), + ) + } + } + fitDayRoute(map, destinations, animated = false) } } } } - LaunchedEffect(focusedBuilding?.code, mapView) { + LaunchedEffect(focusedBuilding?.code, focusedMeeting?.id, mapView) { mapView.getMapAsync { map -> - val focus = focusedBuilding - val target = if (focus == null) LatLng(UTM_LAT, UTM_LON) to CAMPUS_ZOOM - else LatLng(focus.latitude, focus.longitude) to BUILDING_ZOOM - map.animateCamera(CameraUpdateFactory.newLatLngZoom(target.first, target.second), 450) + val point = focusedMeeting?.buildingCode?.let(BuildingPoints::get) ?: focusedBuilding + if (point != null) { + map.animateCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), + 520, + ) + } + } + } + + LaunchedEffect(fitRequest, mapView) { + if (fitRequest > 0) mapView.getMapAsync { map -> fitDayRoute(map, destinations, animated = true) } + } + + LaunchedEffect(campusRequest, mapView) { + if (campusRequest > 0) { + mapView.getMapAsync { map -> + map.animateCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), + 520, + ) + } } } @@ -484,6 +683,72 @@ private fun UtmMap( AndroidView(factory = { mapView }, modifier = modifier) } +private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations: List, animated: Boolean) { + val points = destinations.mapNotNull { meeting -> meeting.buildingCode?.let(BuildingPoints::get) } + if (points.isEmpty()) { + val update = CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM) + if (animated) map.animateCamera(update, 520) else map.moveCamera(update) + return + } + + val centerLat = points.map { it.latitude }.average() + val centerLon = points.map { it.longitude }.average() + val latSpan = points.maxOf { it.latitude } - points.minOf { it.latitude } + val lonSpan = points.maxOf { it.longitude } - points.minOf { it.longitude } + val spread = max(abs(latSpan), abs(lonSpan)) + val zoom = when { + spread < 0.00025 -> 17.35 + spread < 0.001 -> 16.75 + spread < 0.0025 -> 16.15 + spread < 0.0045 -> 15.65 + else -> 15.2 + } + val update = CameraUpdateFactory.newLatLngZoom(LatLng(centerLat, centerLon), zoom) + if (animated) map.animateCamera(update, 520) else map.moveCamera(update) +} + +private fun createTimeMarkerBitmap(label: String, darkTheme: Boolean, density: Float): Bitmap { + val horizontalPadding = 10f * density + val verticalPadding = 6f * density + val tailHeight = 5f * density + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.WHITE + textSize = 11f * density + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + } + val textWidth = textPaint.measureText(label) + val width = (textWidth + horizontalPadding * 2).toInt().coerceAtLeast((58f * density).toInt()) + val bodyHeight = (textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2).toInt() + val height = bodyHeight + tailHeight.toInt() + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (darkTheme) android.graphics.Color.rgb(45, 157, 226) else android.graphics.Color.rgb(20, 107, 184) + } + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1.25f * density + color = if (darkTheme) android.graphics.Color.rgb(113, 198, 255) else android.graphics.Color.rgb(10, 79, 137) + } + val radius = 11f * density + val rect = RectF(0f, 0f, width.toFloat(), bodyHeight.toFloat()) + canvas.drawRoundRect(rect, radius, radius, fill) + canvas.drawRoundRect(rect, radius, radius, stroke) + + val center = width / 2f + val tail = Path().apply { + moveTo(center - 5f * density, bodyHeight.toFloat() - 1f) + lineTo(center, height.toFloat()) + lineTo(center + 5f * density, bodyHeight.toFloat() - 1f) + close() + } + canvas.drawPath(tail, fill) + + val baseline = bodyHeight / 2f - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f + canvas.drawText(label, (width - textWidth) / 2f, baseline, textPaint) + return bitmap +} + private fun formatTimeLabel(minutes: Int): String { val h24 = minutes / 60 val minute = minutes % 60 From 4d8bb9bfde38515fd3349759758d0839d327f588 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:20:10 -0400 Subject: [PATCH 19/29] Match Android account settings to mobile web --- .../feature/settings/AccountSettingsDialog.kt | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 app/src/main/java/ca/gapwise/android/feature/settings/AccountSettingsDialog.kt diff --git a/app/src/main/java/ca/gapwise/android/feature/settings/AccountSettingsDialog.kt b/app/src/main/java/ca/gapwise/android/feature/settings/AccountSettingsDialog.kt new file mode 100644 index 0000000..3b6dc07 --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/settings/AccountSettingsDialog.kt @@ -0,0 +1,255 @@ +package ca.gapwise.android.feature.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import ca.gapwise.android.core.model.Meeting +import ca.gapwise.android.data.account.AccountIdentity +import ca.gapwise.android.data.account.AuthProvider + +private const val GAPWISE_TIMETABLE = "https://gapwise.ca/timetable" +private const val GAPWISE_ROUTE = "https://gapwise.ca/route" +private const val GAPWISE_AI = "https://ai.gapwise.ca" +private const val GAPWISE_MCP = "https://ai.gapwise.ca/api/mcp" + +enum class AccountSettingsTab(val label: String) { ACCOUNT("Account"), EXPORTS("Exports"), AI("AI integrations") } + +@Composable +fun AccountSettingsDialog( + open: Boolean, + account: AccountIdentity?, + meetings: List, + syncEnabled: Boolean, + syncBusy: Boolean, + syncStatus: String?, + onDismiss: () -> Unit, + onSignIn: (AuthProvider) -> Unit, + onSignOut: () -> Unit, + onSetSyncEnabled: (Boolean) -> Unit, + onSyncNow: () -> Unit, + onLoadSync: () -> Unit, + onDeleteSync: () -> Unit, + onDeleteAccount: () -> Unit, +) { + if (!open) return + val context = LocalContext.current + var tab by remember { mutableStateOf(AccountSettingsTab.ACCOUNT) } + var deleteSync by remember { mutableStateOf(false) } + var deleteAccount by remember { mutableStateOf(false) } + fun openUrl(url: String) { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + modifier = Modifier.fillMaxWidth(.92f).heightIn(max = 700.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, top = 16.dp, end = 10.dp, bottom = 14.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f)) { + Text("Settings", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text( + "Manage sync, device storage, exports, and Gapwise AI.", + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + Surface(onClick = onDismiss, shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface) { + Icon(Icons.Outlined.Close, "Close settings", Modifier.padding(8.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = .55f), + ) { + Row(Modifier.padding(3.dp)) { + AccountSettingsTab.entries.forEach { item -> + SettingsTab(item, tab == item, { tab = item }, Modifier.weight(1f)) + } + } + } + when (tab) { + AccountSettingsTab.ACCOUNT -> { + SettingsCard { + Text(if (account != null) "Signed in as" else "Guest mode", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + if (account != null) { + Text(account.email ?: "Gapwise account", Modifier.padding(top = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp) + } else { + Text( + "An account is optional. Planning, device storage, exports, campus tools, and public AI work without signing in.", + Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, lineHeight = 16.sp, + ) + Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AuthProvider.entries.forEach { provider -> + OutlinedButton(onClick = { onSignIn(provider) }) { Text(provider.label, fontSize = 10.5.sp) } + } + } + } + Text( + "Your original ACORN .ics file is not stored in your account.", + Modifier.padding(top = 10.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, + ) + } + SettingsCard { + Text("Keep timetable on this device", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text( + if (meetings.isEmpty()) "No timetable is stored yet." else "Your normalized timetable is encrypted in app-private storage on this device.", + Modifier.padding(top = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, lineHeight = 16.sp, + ) + } + if (account != null) { + SettingsCard { + Text("Sync across devices", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + if (syncStatus != null) Text(syncStatus, Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Button( + onClick = { if (syncEnabled) onSyncNow() else onSetSyncEnabled(true) }, + enabled = !syncBusy, + ) { Text(if (syncEnabled) "Sync now" else "Enable sync", fontSize = 11.sp) } + OutlinedButton(onClick = onLoadSync, enabled = !syncBusy) { Text("Load sync", fontSize = 11.sp) } + } + Row(Modifier.padding(top = 7.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) { + OutlinedButton(onClick = { deleteSync = true }, enabled = !syncBusy) { Text("Delete sync", fontSize = 11.sp) } + OutlinedButton(onClick = onSignOut, enabled = !syncBusy) { Text("Sign out", fontSize = 11.sp) } + } + } + OutlinedButton(onClick = { deleteAccount = true }, modifier = Modifier.fillMaxWidth(), enabled = !syncBusy) { + Text("Delete account and cloud data", fontSize = 11.sp) + } + } + } + AccountSettingsTab.EXPORTS -> { + SettingsCard { + Text("Timetable", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("Export any term or combine every term in one private image or print-ready vector.", Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, lineHeight = 16.sp) + OutlinedButton(onClick = { openUrl(GAPWISE_TIMETABLE) }, modifier = Modifier.padding(top = 10.dp)) { + Icon(Icons.Outlined.Download, null); Text("Export", Modifier.padding(start = 6.dp), fontSize = 11.sp) + } + } + SettingsCard { + Text("UTM map heatmap", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("Export the same campus-focused building and route heatmap as the web app.", Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + OutlinedButton(onClick = { openUrl(GAPWISE_ROUTE) }, modifier = Modifier.padding(top = 10.dp)) { + Icon(Icons.Outlined.Download, null); Text("Export heatmap", Modifier.padding(start = 6.dp), fontSize = 11.sp) + } + } + } + AccountSettingsTab.AI -> { + SettingsCard { + Row(verticalAlignment = Alignment.Top) { + Icon(Icons.Outlined.Link, null, tint = MaterialTheme.colorScheme.tertiary) + Column(Modifier.padding(start = 10.dp).weight(1f)) { + Text("Gapwise AI", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("Public Gapwise AI and the MCP endpoint are available to everyone.", Modifier.padding(top = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) + OutlinedButton(onClick = { openUrl(GAPWISE_AI) }, modifier = Modifier.padding(top = 10.dp)) { + Text("Open Gapwise AI", fontSize = 11.sp); Icon(Icons.Outlined.OpenInNew, null, Modifier.padding(start = 6.dp)) + } + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + shape = RoundedCornerShape(7.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = .35f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { Text(GAPWISE_MCP, Modifier.padding(10.dp), fontSize = 10.sp) } + } + } + } + SettingsCard { + Text(if (account != null) "Private AI access" else "Private AI access is optional", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text( + if (account != null) "Private delegation remains minimized; academic classes are read-only." else "Sign in only if you want to authorize private timetable or student context.", + Modifier.padding(top = 5.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, lineHeight = 16.sp, + ) + } + } + } + } + } + } + } + + if (deleteSync) AlertDialog( + onDismissRequest = { deleteSync = false }, title = { Text("Delete synced Gapwise data?") }, + text = { Text("This removes encrypted cloud data. Your local timetable stays on this device.") }, + confirmButton = { TextButton(onClick = { deleteSync = false; onDeleteSync() }) { Text("Delete sync") } }, + dismissButton = { TextButton(onClick = { deleteSync = false }) { Text("Cancel") } }, + ) + if (deleteAccount) AlertDialog( + onDismissRequest = { deleteAccount = false }, title = { Text("Delete account and cloud data?") }, + text = { Text("This permanently removes your Gapwise account and account-owned cloud data. Your local timetable remains on this device.") }, + confirmButton = { TextButton(onClick = { deleteAccount = false; onDeleteAccount() }) { Text("Permanently delete") } }, + dismissButton = { TextButton(onClick = { deleteAccount = false }) { Text("Cancel") } }, + ) +} + +@Composable +private fun SettingsTab(tab: AccountSettingsTab, selected: Boolean, onClick: () -> Unit, modifier: Modifier) { + Surface( + onClick = onClick, modifier = modifier, shape = RoundedCornerShape(7.dp), + color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0f), + border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null, + ) { + Row(Modifier.padding(horizontal = 6.dp, vertical = 9.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Icon( + when (tab) { AccountSettingsTab.ACCOUNT -> Icons.Outlined.PersonOutline; AccountSettingsTab.EXPORTS -> Icons.Outlined.Download; AccountSettingsTab.AI -> Icons.Outlined.Link }, + null, + ) + Text(tab.label, Modifier.padding(start = 5.dp), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + } + } +} + +@Composable +private fun SettingsCard(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(11.dp), color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { Column(Modifier.padding(14.dp), content = content) } +} From f90b7d09579b8d8634c6c00cc9b8e1e6380755f9 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:20:32 -0400 Subject: [PATCH 20/29] Add focused mobile route preferences sheet --- .../feature/settings/RoutePreferencesSheet.kt | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 app/src/main/java/ca/gapwise/android/feature/settings/RoutePreferencesSheet.kt diff --git a/app/src/main/java/ca/gapwise/android/feature/settings/RoutePreferencesSheet.kt b/app/src/main/java/ca/gapwise/android/feature/settings/RoutePreferencesSheet.kt new file mode 100644 index 0000000..25fa97a --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/settings/RoutePreferencesSheet.kt @@ -0,0 +1,247 @@ +package ca.gapwise.android.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.sp +import ca.gapwise.android.core.persistence.AppPreferences +import ca.gapwise.android.core.persistence.CommuteMode +import ca.gapwise.android.core.persistence.DayOrigin +import ca.gapwise.android.core.persistence.RouteMode +import kotlin.math.roundToInt + +private data class ArrivalChoice(val id: String, val label: String) + +private val Residences = listOf( + ArrivalChoice("EH", "Erindale Hall"), + ArrivalChoice("LL", "Leacock Lane"), + ArrivalChoice("MV", "MaGrath Valley"), + ArrivalChoice("MC", "McLuhan Court"), + ArrivalChoice("OPH", "Oscar Peterson Hall"), + ArrivalChoice("PP", "Putnam Place"), + ArrivalChoice("RIH", "Roy Ivor Hall"), + ArrivalChoice("SW", "Schreiberwood"), + ArrivalChoice("NRB", "New Residence Building"), +) + +private val TransitPoints = listOf( + ArrivalChoice("miway-utm-bus-station", "UTM Bus Station (MiWay)"), + ArrivalChoice("utm-shuttle-instructional-centre", "UTM Shuttle — Instructional Centre"), +) + +private val ParkingPoints = listOf( + ArrivalChoice("parking-p8", "Parking Lot P8"), + ArrivalChoice("parking-p9", "Parking Lot P9"), +) + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun RoutePreferencesSheet( + open: Boolean, + onDismiss: () -> Unit, +) { + if (!open) return + + val context = LocalContext.current + val preferences = remember { AppPreferences(context.applicationContext) } + var routeMode by remember { mutableStateOf(preferences.routeMode()) } + var walkingSpeed by remember { mutableStateOf(preferences.walkingSpeedMps()) } + var transitionBuffer by remember { mutableStateOf(preferences.transitionBufferMinutes()) } + var dayOrigin by remember { mutableStateOf(preferences.dayOrigin()) } + var commuteMode by remember { mutableStateOf(preferences.commuteMode()) } + var residenceCode by remember { mutableStateOf(preferences.residenceBuildingCode() ?: "OPH") } + var accessPointId by remember { mutableStateOf(preferences.campusAccessPointId()) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Route options", fontSize = 19.sp, lineHeight = 23.sp, fontWeight = FontWeight.Medium) + Text( + "The same walking and campus-arrival assumptions used by Gapwise on the web.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + lineHeight = 18.sp, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + PreferenceLabel("Walking") + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + RouteMode.entries.forEach { mode -> + FilterChip( + selected = routeMode == mode, + onClick = { + routeMode = mode + preferences.setRouteMode(mode) + }, + label = { Text(mode.label, fontSize = 11.sp) }, + ) + } + } + CompactSlider( + label = "Walking speed", + valueLabel = "${"%.2f".format(walkingSpeed)} m/s", + value = walkingSpeed, + range = 0.5f..2.5f, + steps = 39, + onValueChange = { walkingSpeed = it }, + onFinished = { preferences.setWalkingSpeedMps(walkingSpeed) }, + ) + CompactSlider( + label = "Transition buffer", + valueLabel = "$transitionBuffer min", + value = transitionBuffer.toFloat(), + range = 0f..30f, + steps = 29, + onValueChange = { transitionBuffer = it.roundToInt() }, + onFinished = { preferences.setTransitionBufferMinutes(transitionBuffer) }, + ) + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + PreferenceLabel("Campus arrival") + Text( + "Gapwise stores where your campus walk begins — never your home address.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 17.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = dayOrigin == DayOrigin.RESIDENCE, + onClick = { + dayOrigin = DayOrigin.RESIDENCE + commuteMode = null + accessPointId = null + preferences.setDayOrigin(dayOrigin) + preferences.setCommuteMode(null) + preferences.setCampusAccessPointId(null) + preferences.setResidenceBuildingCode(residenceCode) + }, + label = { Text("Live on campus", fontSize = 11.sp) }, + ) + FilterChip( + selected = dayOrigin == DayOrigin.COMMUTE, + onClick = { + dayOrigin = DayOrigin.COMMUTE + preferences.setDayOrigin(dayOrigin) + preferences.setResidenceBuildingCode(null) + }, + label = { Text("Commute", fontSize = 11.sp) }, + ) + } + + if (dayOrigin == DayOrigin.RESIDENCE) { + Text("Residence building", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + LazyRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + items(Residences, key = { it.id }) { residence -> + FilterChip( + selected = residenceCode == residence.id, + onClick = { + residenceCode = residence.id + preferences.setResidenceBuildingCode(residence.id) + }, + label = { Text("${residence.label} (${residence.id})", fontSize = 10.5.sp) }, + ) + } + } + } else { + Text("How you arrive", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + LazyRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + items(CommuteMode.entries) { mode -> + FilterChip( + selected = commuteMode == mode, + onClick = { + commuteMode = mode + accessPointId = null + preferences.setCommuteMode(mode) + preferences.setCampusAccessPointId(null) + }, + label = { Text(mode.label, fontSize = 10.5.sp) }, + ) + } + } + val points = when (commuteMode) { + CommuteMode.TRANSIT -> TransitPoints + CommuteMode.PARKING -> ParkingPoints + CommuteMode.PICKUP, null -> emptyList() + } + if (points.isNotEmpty()) { + Text("Campus arrival point", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + LazyRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + items(points, key = { it.id }) { point -> + FilterChip( + selected = accessPointId == point.id, + onClick = { + accessPointId = point.id + preferences.setCampusAccessPointId(point.id) + }, + label = { Text(point.label, fontSize = 10.5.sp) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun PreferenceLabel(text: String) { + Text( + text.uppercase(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + ) +} + +@Composable +private fun CompactSlider( + label: String, + valueLabel: String, + value: Float, + range: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit, + onFinished: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(valueLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) + } + Slider( + value = value.coerceIn(range.start, range.endInclusive), + onValueChange = onValueChange, + onValueChangeFinished = onFinished, + valueRange = range, + steps = steps, + ) + } +} From 3b4516ba8a76238adfedd23210c7d662cce0ed3d Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:20:56 -0400 Subject: [PATCH 21/29] Match More account menu to mobile web --- .../feature/settings/CompactMoreSheet.kt | 129 ++++++++++++++---- 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt b/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt index 17c3a8d..7526b3d 100644 --- a/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt +++ b/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt @@ -2,19 +2,26 @@ package ca.gapwise.android.feature.settings import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Logout import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Upload import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,6 +29,10 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -33,18 +44,21 @@ import ca.gapwise.android.core.persistence.AppThemeMode fun CompactMoreSheetContent( themeMode: AppThemeMode, accountLabel: String, + signedIn: Boolean, canRemoveTimetable: Boolean, onOpenAcademicPreferences: () -> Unit, onToggleTheme: () -> Unit, onOpenArrivalPreferences: () -> Unit, - onOpenAccount: () -> Unit, + onOpenSettings: () -> Unit, + onSignOut: () -> Unit, + onDeleteAccount: () -> Unit, onUpdateTimetable: () -> Unit, onRemoveTimetable: () -> Unit, ) { + var accountMenuOpen by remember { mutableStateOf(false) } + Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), ) { Text( text = "PREFERENCES", @@ -76,28 +90,87 @@ fun CompactMoreSheetContent( } item { CompactPreferenceChip("Arrival", onOpenArrivalPreferences) } item { - Surface( - onClick = onOpenAccount, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.background, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, + Box { + Surface( + onClick = { if (signedIn) accountMenuOpen = true else onOpenSettings() }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.background, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - Icon(imageVector = Icons.Outlined.PersonOutline, contentDescription = null) - Text( - accountLabel, - modifier = Modifier.padding(start = 7.dp), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.Outlined.PersonOutline, contentDescription = null) + Text( + accountLabel, + modifier = Modifier.padding(start = 7.dp), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + if (signedIn) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + + DropdownMenu( + expanded = accountMenuOpen, + onDismissRequest = { accountMenuOpen = false }, + modifier = Modifier.widthIn(min = 235.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text("Account", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text( + "You'll stay signed in on this device until you sign out.", + modifier = Modifier.padding(top = 3.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.5.sp, + lineHeight = 15.sp, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + DropdownMenuItem( + text = { Text("Settings", fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, + onClick = { + accountMenuOpen = false + onOpenSettings() + }, + ) + DropdownMenuItem( + text = { Text("Sign out", fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Outlined.Logout, contentDescription = null) }, + onClick = { + accountMenuOpen = false + onSignOut() + }, ) - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.padding(start = 4.dp), + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + DropdownMenuItem( + text = { + Text( + "Delete account and cloud data", + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + accountMenuOpen = false + onDeleteAccount() + }, ) } } @@ -119,9 +192,7 @@ fun CompactMoreSheetContent( ) Button( onClick = onUpdateTimetable, - modifier = Modifier - .fillMaxWidth() - .padding(top = 9.dp), + modifier = Modifier.fillMaxWidth().padding(top = 9.dp), shape = RoundedCornerShape(9.dp), ) { Icon(imageVector = Icons.Outlined.Upload, contentDescription = null) @@ -130,9 +201,7 @@ fun CompactMoreSheetContent( if (canRemoveTimetable) { OutlinedButton( onClick = onRemoveTimetable, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(9.dp), ) { Text("Remove timetable", color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold) From 4fd7945963948a1663228364fb9c1a34e64fa2d6 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:21:31 -0400 Subject: [PATCH 22/29] Wire gap navigation and web-style settings --- .../gapwise/android/navigation/GapwiseApp.kt | 308 +++++++++--------- 1 file changed, 146 insertions(+), 162 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt b/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt index 1fab40d..a301b87 100644 --- a/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt +++ b/app/src/main/java/ca/gapwise/android/navigation/GapwiseApp.kt @@ -64,13 +64,15 @@ import ca.gapwise.android.data.sync.EncryptedCloudSync import ca.gapwise.android.data.timetable.IcsParser import ca.gapwise.android.feature.gapplan.GapPlanScreen import ca.gapwise.android.feature.map.MapScreen +import ca.gapwise.android.feature.settings.AccountSettingsDialog import ca.gapwise.android.feature.settings.CompactMoreSheetContent -import ca.gapwise.android.feature.settings.SettingsScreen +import ca.gapwise.android.feature.settings.RoutePreferencesSheet import ca.gapwise.android.feature.timetable.TimetableScreen import ca.gapwise.android.feature.today.TodayScreen import kotlinx.coroutines.launch private const val TIMETABLE_ENTRY = "timetable.normalized" +private const val GAPWISE_TODAY_URL = "https://gapwise.ca/today" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -94,9 +96,7 @@ fun GapwiseApp( val cloudSync = remember { EncryptedCloudSync(accountManager) } val cloudMaintenance = remember { EncryptedCloudMaintenance(accountManager) } - var meetings by remember { - mutableStateOf(MeetingJson.decodeLocal(secureStore.get(TIMETABLE_ENTRY))) - } + var meetings by remember { mutableStateOf(MeetingJson.decodeLocal(secureStore.get(TIMETABLE_ENTRY))) } var importStatus by remember { mutableStateOf(null) } var account by remember { mutableStateOf(accountManager.storedIdentity()) } var syncEnabled by remember(account?.userId) { @@ -105,7 +105,8 @@ fun GapwiseApp( var syncBusy by remember { mutableStateOf(false) } var syncStatus by remember { mutableStateOf(null) } var moreOpen by remember { mutableStateOf(false) } - var settingsOpen by remember { mutableStateOf(false) } + var accountSettingsOpen by remember { mutableStateOf(false) } + var routePreferencesOpen by remember { mutableStateOf(false) } fun saveTimetable(value: List) { meetings = value @@ -123,20 +124,25 @@ fun GapwiseApp( } } + fun signOut() { + scope.launch { + syncBusy = true + accountManager.signOut() + account = null + syncEnabled = false + syncStatus = "Signed out. Your timetable remains saved on this device." + syncBusy = false + } + } + val calendarPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult val result = runCatching { - val knownLength = context.contentResolver - .openAssetFileDescriptor(uri, "r") - ?.use { it.length } - ?: -1L + val knownLength = context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { it.length } ?: -1L require(knownLength <= 0L || knownLength <= IcsParser.MAX_ICS_CHARS * 2L) { "Calendar is too large to import safely." } - val text = context.contentResolver - .openInputStream(uri) - ?.bufferedReader() - ?.use { it.readText() } + val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: error("Could not read this calendar.") IcsParser.parse(text) } @@ -162,11 +168,10 @@ fun GapwiseApp( runCatching { accountManager.completeOAuth(callback) } .onSuccess { identity -> account = identity + syncEnabled = preferences.encryptedSyncEnabled(identity.userId) syncStatus = "Signed in. Turn on encrypted account sync when you want this device linked." } - .onFailure { error -> - syncStatus = error.message ?: "Sign in failed." - } + .onFailure { error -> syncStatus = error.message ?: "Sign in failed." } syncBusy = false onAuthCallbackConsumed() } @@ -210,7 +215,17 @@ fun GapwiseApp( ) } composable(Destination.Timetable.route) { - TimetableScreen(meetings = meetings, importStatus = importStatus, onImport = startImport) + TimetableScreen( + meetings = meetings, + importStatus = importStatus, + onImport = startImport, + onOpenGapPlan = { + navController.navigate(Destination.Gaps.route) { + launchSingleTop = true + restoreState = true + } + }, + ) } composable(Destination.Gaps.route) { GapPlanScreen(meetings = meetings, onImport = startImport) @@ -240,22 +255,32 @@ fun GapwiseApp( ) CompactMoreSheetContent( themeMode = themeMode, - accountLabel = account?.email?.substringBefore('@')?.takeIf { it.isNotBlank() } ?: "Account", + accountLabel = account?.email?.substringBefore('@')?.takeIf { it.isNotBlank() } + ?: if (account == null) "Sign in" else "Account", + signedIn = account != null, canRemoveTimetable = meetings.isNotEmpty(), onOpenAcademicPreferences = { moreOpen = false - settingsOpen = true + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(GAPWISE_TODAY_URL))) } }, onToggleTheme = { onThemeModeChange(if (themeMode == AppThemeMode.DARK) AppThemeMode.LIGHT else AppThemeMode.DARK) }, onOpenArrivalPreferences = { moreOpen = false - settingsOpen = true + routePreferencesOpen = true + }, + onOpenSettings = { + moreOpen = false + accountSettingsOpen = true + }, + onSignOut = { + moreOpen = false + signOut() }, - onOpenAccount = { + onDeleteAccount = { moreOpen = false - settingsOpen = true + accountSettingsOpen = true }, onUpdateTimetable = { moreOpen = false @@ -271,153 +296,112 @@ fun GapwiseApp( } } - if (settingsOpen) { - ModalBottomSheet( - onDismissRequest = { settingsOpen = false }, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - ) { - Text( - text = "Preferences", - fontSize = 19.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(top = 8.dp, bottom = 14.dp), - ) - SettingsScreen( - meetings = meetings, - importStatus = importStatus, - themeMode = themeMode, - account = account, - syncEnabled = syncEnabled, - syncBusy = syncBusy, - syncStatus = syncStatus, - onThemeModeChange = onThemeModeChange, - onImport = { - settingsOpen = false - startImport() - }, - onClearTimetable = { - saveTimetable(emptyList()) - importStatus = "Timetable removed from this device." - pushIfEnabled(emptyList()) - }, - onSignIn = { provider: AuthProvider -> - runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, accountManager.startOAuth(provider))) - }.onFailure { error -> - syncStatus = error.message ?: "Could not open sign in." - } - }, - onSignOut = { + RoutePreferencesSheet( + open = routePreferencesOpen, + onDismiss = { routePreferencesOpen = false }, + ) + + AccountSettingsDialog( + open = accountSettingsOpen, + account = account, + meetings = meetings, + syncEnabled = syncEnabled, + syncBusy = syncBusy, + syncStatus = syncStatus, + onDismiss = { accountSettingsOpen = false }, + onSignIn = { provider: AuthProvider -> + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, accountManager.startOAuth(provider))) } + .onFailure { error -> syncStatus = error.message ?: "Could not open sign in." } + }, + onSignOut = { + accountSettingsOpen = false + signOut() + }, + onSetSyncEnabled = { enabled -> + val identity = account + if (identity != null) { + preferences.setEncryptedSyncEnabled(identity.userId, enabled) + syncEnabled = enabled + if (enabled) { scope.launch { syncBusy = true - accountManager.signOut() - account = null - syncEnabled = false - syncStatus = "Signed out. Your timetable remains saved on this device." - syncBusy = false - } - }, - onSetSyncEnabled = { enabled -> - val identity = account - if (identity != null) { - preferences.setEncryptedSyncEnabled(identity.userId, enabled) - syncEnabled = enabled - if (enabled) { - scope.launch { - syncBusy = true - runCatching { cloudSync.pullOrInitialize(meetings) } - .onSuccess { synced -> - saveTimetable(synced.meetings) - syncStatus = synced.message - } - .onFailure { error -> - preferences.setEncryptedSyncEnabled(identity.userId, false) - syncEnabled = false - syncStatus = error.message ?: "Encrypted sync could not be enabled." - } - syncBusy = false + runCatching { cloudSync.pullOrInitialize(meetings) } + .onSuccess { synced -> + saveTimetable(synced.meetings) + syncStatus = synced.message } - } else { - syncStatus = "Encrypted account sync paused. Cloud data was not deleted." - } - } - }, - onSyncNow = { - if (account != null && syncEnabled) { - scope.launch { - syncBusy = true - runCatching { cloudSync.pushSchedule(meetings) } - .onSuccess { synced -> syncStatus = synced.message } - .onFailure { error -> syncStatus = error.message ?: "Encrypted sync failed." } - syncBusy = false - } + .onFailure { error -> + preferences.setEncryptedSyncEnabled(identity.userId, false) + syncEnabled = false + syncStatus = error.message ?: "Encrypted sync could not be enabled." + } + syncBusy = false } - }, - onLoadSync = { - if (account != null) { - scope.launch { - syncBusy = true - runCatching { cloudSync.pullOrInitialize(emptyList()) } - .onSuccess { synced -> - if (!synced.message.contains("Nothing is stored in the cloud yet")) { - saveTimetable(synced.meetings) - } - syncStatus = synced.message - } - .onFailure { error -> syncStatus = error.message ?: "Encrypted sync failed." } - syncBusy = false + } else { + syncStatus = "Encrypted account sync paused. Cloud data was not deleted." + } + } + }, + onSyncNow = { + if (account != null && syncEnabled) { + scope.launch { + syncBusy = true + runCatching { cloudSync.pushSchedule(meetings) } + .onSuccess { synced -> syncStatus = synced.message } + .onFailure { error -> syncStatus = error.message ?: "Encrypted sync failed." } + syncBusy = false + } + } + }, + onLoadSync = { + if (account != null) { + scope.launch { + syncBusy = true + runCatching { cloudSync.pullOrInitialize(emptyList()) } + .onSuccess { synced -> + if (!synced.message.contains("Nothing is stored in the cloud yet")) saveTimetable(synced.meetings) + syncStatus = synced.message } - } - }, - onDeleteSync = { - val identity = account - if (identity != null) { - scope.launch { - syncBusy = true - runCatching { cloudMaintenance.deletePrivateCloud() } - .onSuccess { - preferences.setEncryptedSyncEnabled(identity.userId, false) - syncEnabled = false - syncStatus = "Encrypted synced data deleted. The timetable remains on this device." - } - .onFailure { error -> - syncStatus = error.message ?: "Encrypted cloud deletion failed." - } - syncBusy = false + .onFailure { error -> syncStatus = error.message ?: "Encrypted sync failed." } + syncBusy = false + } + } + }, + onDeleteSync = { + val identity = account + if (identity != null) { + scope.launch { + syncBusy = true + runCatching { cloudMaintenance.deletePrivateCloud() } + .onSuccess { + preferences.setEncryptedSyncEnabled(identity.userId, false) + syncEnabled = false + syncStatus = "Encrypted synced data deleted. The timetable remains on this device." } - } - }, - onDeleteAccount = { clearLocal -> - val identity = account - if (identity != null) { - scope.launch { - syncBusy = true - runCatching { accountMaintenance.deleteAccount() } - .onSuccess { - preferences.setEncryptedSyncEnabled(identity.userId, false) - account = null - syncEnabled = false - if (clearLocal) { - saveTimetable(emptyList()) - importStatus = "Local timetable removed with your account." - } - syncStatus = "Your Gapwise account and cloud data were permanently deleted." - } - .onFailure { error -> - syncStatus = error.message ?: "We couldn't delete your account. Please try again." - } - syncBusy = false + .onFailure { error -> syncStatus = error.message ?: "Encrypted cloud deletion failed." } + syncBusy = false + } + } + }, + onDeleteAccount = { + val identity = account + if (identity != null) { + scope.launch { + syncBusy = true + runCatching { accountMaintenance.deleteAccount() } + .onSuccess { + preferences.setEncryptedSyncEnabled(identity.userId, false) + account = null + syncEnabled = false + accountSettingsOpen = false + syncStatus = "Your Gapwise account and cloud data were permanently deleted." } - } - }, - ) - } - } + .onFailure { error -> syncStatus = error.message ?: "We couldn't delete your account. Please try again." } + syncBusy = false + } + } + }, + ) } @Composable From 21f47fe2b58965f309b96d51e8a8e10db4d0a4de Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:22:32 -0400 Subject: [PATCH 23/29] Make native campus map interactive and smooth like mobile web --- .../gapwise/android/feature/map/MapScreen.kt | 453 ++++++++++-------- 1 file changed, 242 insertions(+), 211 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index 7607bf1..c126b57 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path +import android.graphics.PointF import android.graphics.RectF import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -29,7 +30,6 @@ import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,10 +37,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,6 +58,7 @@ import ca.gapwise.android.core.model.Campus import ca.gapwise.android.core.model.LocationType import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term +import ca.gapwise.android.feature.settings.RoutePreferencesSheet import org.maplibre.android.MapLibre import org.maplibre.android.annotations.IconFactory import org.maplibre.android.annotations.MarkerOptions @@ -66,9 +67,18 @@ import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.FillExtrusionLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory.fillColor +import org.maplibre.android.style.layers.PropertyFactory.fillOpacity +import org.maplibre.android.style.layers.PropertyFactory.fillOutlineColor +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature import java.time.DayOfWeek import java.time.LocalDate import kotlin.math.abs +import kotlin.math.hypot import kotlin.math.max private const val UTM_LAT = 43.55105 @@ -77,6 +87,8 @@ private const val CAMPUS_ZOOM = 16.0 private const val BUILDING_ZOOM = 17.6 private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron" private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark" +private const val SELECTED_BUILDING_SOURCE = "gapwise-selected-building-source" +private const val SELECTED_BUILDING_FILL = "gapwise-selected-building-fill" private val BaseDays = listOf( DayOfWeek.MONDAY, @@ -110,7 +122,6 @@ private val BuildingPoints = listOf( BuildingPoint("DW", "Erindale Studio Theatre", 43.5499078, -79.6661378), ).associateBy { it.code } -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun MapScreen( meetings: List, @@ -170,20 +181,14 @@ fun MapScreen( modifier = Modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp), ) { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) + Icon(Icons.Outlined.Tune, contentDescription = null, modifier = Modifier.size(14.dp)) Text("Options", modifier = Modifier.padding(start = 6.dp), fontSize = 11.sp, fontWeight = FontWeight.SemiBold) } } if (availableTerms.size > 1) { Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), shape = WebControlShape, color = MaterialTheme.colorScheme.background, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), @@ -203,9 +208,7 @@ fun MapScreen( } Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 9.dp), + modifier = Modifier.fillMaxWidth().padding(top = 9.dp), shape = WebControlShape, color = MaterialTheme.colorScheme.background, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), @@ -226,30 +229,11 @@ fun MapScreen( } item { - CampusMapCard( - meetings = dayMeetings, - darkTheme = darkTheme, - ) + CampusMapCard(meetings = dayMeetings, darkTheme = darkTheme) } } - if (optionsOpen) { - ModalBottomSheet( - onDismissRequest = { optionsOpen = false }, - containerColor = MaterialTheme.colorScheme.surface, - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text("Route options", fontSize = 19.sp, fontWeight = FontWeight.SemiBold) - Text( - "Walking speed, transition buffer, indoor preference, and step-free routing are available from More.", - modifier = Modifier.padding(top = 7.dp, bottom = 24.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 13.sp, - lineHeight = 20.sp, - ) - } - } - } + RoutePreferencesSheet(open = optionsOpen, onDismiss = { optionsOpen = false }) } @Composable @@ -273,41 +257,39 @@ private fun CampusMapCard( if (normalized.isBlank()) emptyList() else BuildingPoints.values.filter { point -> val code = point.code.lowercase() - point.code.lowercase().contains(normalized) || - point.name.lowercase().contains(normalized) || - compact.startsWith(code) + code.contains(normalized) || point.name.lowercase().contains(normalized) || compact.startsWith(code) }.take(8) } val selected = selectedCode?.let(BuildingPoints::get) val selectedMeeting = selectedMeetingId?.let { id -> destinations.firstOrNull { it.id == id } } - val mapKey = destinations.joinToString("|") { "${it.id}:${it.buildingCode}:${it.startTime}" } Surface( - modifier = Modifier - .fillMaxWidth() - .height(390.dp), + modifier = Modifier.fillMaxWidth().height(390.dp), shape = RoundedCornerShape(11.dp), color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 0.dp, ) { Box(modifier = Modifier.fillMaxSize()) { - key(darkTheme, mapKey) { - UtmMap( - destinations = destinations, - darkTheme = darkTheme, - focusedBuilding = selected, - focusedMeeting = selectedMeeting, - fitRequest = fitRequest, - campusRequest = campusRequest, - onMeetingSelected = { id -> - selectedMeetingId = id - selectedCode = null - query = "" - }, - modifier = Modifier.fillMaxSize(), - ) - } + UtmMap( + destinations = destinations, + darkTheme = darkTheme, + focusedBuilding = selected, + focusedMeeting = selectedMeeting, + fitRequest = fitRequest, + campusRequest = campusRequest, + onMeetingSelected = { id -> + selectedMeetingId = id + selectedCode = null + query = "" + }, + onBuildingSelected = { code -> + selectedMeetingId = null + selectedCode = code + query = "" + }, + modifier = Modifier.fillMaxSize(), + ) Column( modifier = Modifier @@ -325,21 +307,14 @@ private fun CampusMapCard( modifier = Modifier.padding(horizontal = 12.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Outlined.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) + Icon(Icons.Outlined.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) BasicTextField( value = query, onValueChange = { query = it }, singleLine = true, textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiary), - modifier = Modifier - .weight(1f) - .padding(start = 9.dp), + modifier = Modifier.weight(1f).padding(start = 9.dp), decorationBox = { inner -> if (query.isBlank()) { Text("Search MN, Deerfield, Kaneff…", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) @@ -352,9 +327,7 @@ private fun CampusMapCard( if (query.isNotBlank()) { Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 5.dp), + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), shape = RoundedCornerShape(9.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.99f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), @@ -393,12 +366,7 @@ private fun CampusMapCard( fontWeight = FontWeight.Bold, ) } - Text( - building.name, - modifier = Modifier.padding(start = 10.dp), - fontSize = 12.dp.value.sp, - fontWeight = FontWeight.SemiBold, - ) + Text(building.name, modifier = Modifier.padding(start = 10.dp), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } } } @@ -408,9 +376,7 @@ private fun CampusMapCard( } Column( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 84.dp, end = 10.dp), + modifier = Modifier.align(Alignment.TopEnd).padding(top = 84.dp, end = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { MapControl(Icons.Outlined.Maximize) { @@ -429,19 +395,13 @@ private fun CampusMapCard( MeetingMapCard( meeting = selectedMeeting, onClose = { selectedMeetingId = null }, - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .padding(10.dp), + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(10.dp), ) } else if (selected != null) { BuildingMapCard( building = selected, onClose = { selectedCode = null }, - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .padding(10.dp), + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(10.dp), ) } } @@ -461,12 +421,7 @@ private fun MeetingMapCard(meeting: Meeting, onClose: () -> Unit, modifier: Modi Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Column(modifier = Modifier.weight(1f)) { WebEyebrow("${meeting.buildingCode ?: "UTM"} · UTM") - Text( - point?.name ?: meeting.locationLabel, - modifier = Modifier.padding(top = 4.dp), - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) + Text(point?.name ?: meeting.locationLabel, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) Text( "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", modifier = Modifier.padding(top = 5.dp), @@ -474,16 +429,7 @@ private fun MeetingMapCard(meeting: Meeting, onClose: () -> Unit, modifier: Modi fontSize = 11.sp, ) } - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = "Close class details", - modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(7.dp)) - .clickable(onClick = onClose) - .padding(7.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + CloseMapCardButton("Close class details", onClose) } } } @@ -501,41 +447,17 @@ private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifi Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Column(modifier = Modifier.weight(1f)) { WebEyebrow("${building.code} · ${building.kind}") - Text( - building.name, - modifier = Modifier.padding(top = 5.dp), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) + Text(building.name, modifier = Modifier.padding(top = 5.dp), fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = "Close building details", - modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(7.dp)) - .clickable(onClick = onClose) - .padding(7.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + CloseMapCardButton("Close building details", onClose) } Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - "${building.mappedEntrances} mapped ${if (building.mappedEntrances == 1) "entrance" else "entrances"}", - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - "Partial coverage", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 10.5.sp, - ) + Text("${building.mappedEntrances} mapped ${if (building.mappedEntrances == 1) "entrance" else "entrances"}", fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + Text("Partial coverage", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) } Text( "${building.mappedEntrances} source-backed mapped ${if (building.mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped.", @@ -548,13 +470,20 @@ private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifi } } +@Composable +private fun CloseMapCardButton(description: String, onClose: () -> Unit) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = description, + modifier = Modifier.size(30.dp).clip(RoundedCornerShape(7.dp)).clickable(onClick = onClose).padding(7.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + @Composable private fun MapControl(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) { Surface( - modifier = Modifier - .size(38.dp) - .clip(RoundedCornerShape(9.dp)) - .clickable(onClick = onClick), + modifier = Modifier.size(38.dp).clip(RoundedCornerShape(9.dp)).clickable(onClick = onClick), shape = RoundedCornerShape(9.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), @@ -574,11 +503,15 @@ private fun UtmMap( fitRequest: Int, campusRequest: Int, onMeetingSelected: (String) -> Unit, + onBuildingSelected: (String) -> Unit, modifier: Modifier, ) { val context = androidx.compose.ui.platform.LocalContext.current val styleUrl = if (darkTheme) DARK_STYLE else LIGHT_STYLE - val mapView = remember(styleUrl, destinations) { + val meetingSelection by rememberUpdatedState(onMeetingSelected) + val buildingSelection by rememberUpdatedState(onBuildingSelected) + + val mapView = remember(context, styleUrl) { MapLibre.getInstance(context) MapView(context).apply { onCreate(null) @@ -589,108 +522,208 @@ private fun UtmMap( map.uiSettings.setAttributionEnabled(true) map.uiSettings.setCompassEnabled(false) map.uiSettings.setAllGesturesEnabled(true) - map.cameraPosition = CameraPosition.Builder() - .target(LatLng(UTM_LAT, UTM_LON)) - .zoom(CAMPUS_ZOOM) - .build() - map.setOnMarkerClickListener { marker -> - val snippet = marker.snippet.orEmpty() - if (snippet.startsWith("meeting:")) { - onMeetingSelected(snippet.removePrefix("meeting:")) - true - } else { - false - } - } - map.setStyle(styleUrl) { - val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() - val routePoints = destinations.mapNotNull { meeting -> - meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> - LatLng(point.latitude, point.longitude) - } - } - if (routePoints.size > 1 && routePoints.distinct().size > 1) { - map.addPolyline( - PolylineOptions() - .addAll(routePoints) - .color(accent) - .width(4f), - ) - } + map.cameraPosition = CameraPosition.Builder().target(LatLng(UTM_LAT, UTM_LON)).zoom(CAMPUS_ZOOM).build() + map.setStyle(styleUrl) + } + } + } + + DisposableEffect(mapView) { + var mapClickListener: org.maplibre.android.maps.MapLibreMap.OnMapClickListener? = null + mapView.getMapAsync { map -> + map.setOnMarkerClickListener { marker -> + val snippet = marker.snippet.orEmpty() + if (snippet.startsWith("meeting:")) { + meetingSelection(snippet.removePrefix("meeting:")) + true + } else false + } + val listener = org.maplibre.android.maps.MapLibreMap.OnMapClickListener { latLng -> + val style = map.style ?: return@OnMapClickListener false + val feature = renderedBuildingFeature(map, style, latLng, preferredCode = null) + ?: return@OnMapClickListener false + val building = buildingForFeature(feature, latLng) ?: return@OnMapClickListener false + highlightFeature(style, feature, darkTheme) + buildingSelection(building.code) + true + } + map.addOnMapClickListener(listener) + mapClickListener = listener + } + onDispose { + mapView.getMapAsync { map -> mapClickListener?.let(map::removeOnMapClickListener) } + mapView.onPause() + mapView.onStop() + mapView.onDestroy() + } + } - val grouped = destinations.groupBy { it.buildingCode } - grouped.forEach { (code, buildingMeetings) -> - val point = code?.let(BuildingPoints::get) ?: return@forEach - buildingMeetings.sortedBy { it.startTime }.forEachIndexed { index, meeting -> - val latitudeOffset = index * 0.000075 - map.addMarker( - MarkerOptions() - .position(LatLng(point.latitude + latitudeOffset, point.longitude)) - .icon( - IconFactory.getInstance(context).fromBitmap( - createTimeMarkerBitmap( - label = formatTimeLabel(meeting.startTime), - darkTheme = darkTheme, - density = context.resources.displayMetrics.density, - ), + LaunchedEffect(destinations, mapView, darkTheme) { + mapView.getMapAsync { map -> + map.getStyle { style -> + map.removeAnnotations() + val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() + val routePoints = destinations.mapNotNull { meeting -> + meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> LatLng(point.latitude, point.longitude) } + } + if (routePoints.size > 1 && routePoints.distinct().size > 1) { + map.addPolyline(PolylineOptions().addAll(routePoints).color(accent).width(4f)) + } + val grouped = destinations.groupBy { it.buildingCode } + grouped.forEach { (code, buildingMeetings) -> + val point = code?.let(BuildingPoints::get) ?: return@forEach + buildingMeetings.sortedBy { it.startTime }.forEachIndexed { index, meeting -> + val latitudeOffset = index * 0.000075 + map.addMarker( + MarkerOptions() + .position(LatLng(point.latitude + latitudeOffset, point.longitude)) + .icon( + IconFactory.getInstance(context).fromBitmap( + createTimeMarkerBitmap( + label = formatTimeLabel(meeting.startTime), + darkTheme = darkTheme, + density = context.resources.displayMetrics.density, ), - ) - .snippet("meeting:${meeting.id}"), - ) - } + ), + ) + .snippet("meeting:${meeting.id}"), + ) } - fitDayRoute(map, destinations, animated = false) } + if (focusedBuilding == null && focusedMeeting == null) clearBuildingHighlight(style) + fitDayRoute(map, destinations, animated = true) } } } - LaunchedEffect(focusedBuilding?.code, focusedMeeting?.id, mapView) { + LaunchedEffect(focusedBuilding?.code, focusedMeeting?.id, mapView, darkTheme) { mapView.getMapAsync { map -> val point = focusedMeeting?.buildingCode?.let(BuildingPoints::get) ?: focusedBuilding - if (point != null) { - map.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), - 520, - ) + map.getStyle { style -> + if (point == null) { + clearBuildingHighlight(style) + return@getStyle + } + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), 420) + mapView.postDelayed({ + map.getStyle { currentStyle -> + val feature = renderedBuildingFeature( + map, + currentStyle, + LatLng(point.latitude, point.longitude), + preferredCode = point.code, + ) + if (feature != null) highlightFeature(currentStyle, feature, darkTheme) + } + }, 440) } } } LaunchedEffect(fitRequest, mapView) { - if (fitRequest > 0) mapView.getMapAsync { map -> fitDayRoute(map, destinations, animated = true) } + if (fitRequest > 0) mapView.getMapAsync { map -> + map.getStyle { style -> clearBuildingHighlight(style) } + fitDayRoute(map, destinations, animated = true) + } } LaunchedEffect(campusRequest, mapView) { - if (campusRequest > 0) { - mapView.getMapAsync { map -> - map.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), - 520, - ) - } + if (campusRequest > 0) mapView.getMapAsync { map -> + map.getStyle { style -> clearBuildingHighlight(style) } + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 420) } } - DisposableEffect(mapView) { - onDispose { - mapView.onPause() - mapView.onStop() - mapView.onDestroy() - } + AndroidView(factory = { mapView }, modifier = modifier) +} + +private fun renderedBuildingFeature( + map: org.maplibre.android.maps.MapLibreMap, + style: Style, + coordinate: LatLng, + preferredCode: String?, +): Feature? { + val layerIds = buildingLayerIds(style) + if (layerIds.isEmpty()) return null + val center = map.projection.toScreenLocation(coordinate) + val offsets = listOf( + 0f to 0f, + 8f to 0f, + -8f to 0f, + 0f to 8f, + 0f to -8f, + 14f to 14f, + -14f to 14f, + 14f to -14f, + -14f to -14f, + ) + val features = offsets.asSequence() + .flatMap { (dx, dy) -> map.queryRenderedFeatures(PointF(center.x + dx, center.y + dy), layerIds).asSequence() } + .distinctBy { it.id() ?: it.toJson() } + .toList() + if (features.isEmpty()) return null + if (preferredCode != null) { + val building = BuildingPoints[preferredCode] + val match = features.firstOrNull { featureMatchesBuilding(it, building) } + if (match != null) return match } + return features.firstOrNull() +} - AndroidView(factory = { mapView }, modifier = modifier) +private fun buildingLayerIds(style: Style): Array = style.layers.mapNotNull { layer -> + when (layer) { + is FillLayer -> if (layer.sourceLayer == "building") layer.id else null + is FillExtrusionLayer -> if (layer.sourceLayer == "building") layer.id else null + else -> null + } +}.toTypedArray() + +private fun featureMatchesBuilding(feature: Feature, building: BuildingPoint?): Boolean { + if (building == null) return false + val candidates = listOf("ref", "name", "name:en", "short_name") + .mapNotNull { key -> + runCatching { if (feature.hasProperty(key)) feature.getStringProperty(key) else null }.getOrNull() + } + .map { it.trim().lowercase() } + val code = building.code.lowercase() + val name = building.name.lowercase() + return candidates.any { it == code || it == name || it.contains(name) } +} + +private fun buildingForFeature(feature: Feature, tap: LatLng): BuildingPoint? { + BuildingPoints.values.firstOrNull { featureMatchesBuilding(feature, it) }?.let { return it } + val nearest = BuildingPoints.values.minByOrNull { point -> + hypot(point.latitude - tap.latitude, (point.longitude - tap.longitude) * 0.73) + } ?: return null + val distance = hypot(nearest.latitude - tap.latitude, (nearest.longitude - tap.longitude) * 0.73) + return nearest.takeIf { distance < 0.00135 } +} + +private fun highlightFeature(style: Style, feature: Feature, darkTheme: Boolean) { + clearBuildingHighlight(style) + style.addSource(GeoJsonSource(SELECTED_BUILDING_SOURCE, feature)) + val accent = if (darkTheme) "#60A5FA" else "#146BB8" + style.addLayer( + FillLayer(SELECTED_BUILDING_FILL, SELECTED_BUILDING_SOURCE).withProperties( + fillColor(accent), + fillOpacity(0.28f), + fillOutlineColor(accent), + ), + ) +} + +private fun clearBuildingHighlight(style: Style) { + if (style.getLayer(SELECTED_BUILDING_FILL) != null) style.removeLayer(SELECTED_BUILDING_FILL) + if (style.getSource(SELECTED_BUILDING_SOURCE) != null) style.removeSource(SELECTED_BUILDING_SOURCE) } private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations: List, animated: Boolean) { val points = destinations.mapNotNull { meeting -> meeting.buildingCode?.let(BuildingPoints::get) } if (points.isEmpty()) { val update = CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM) - if (animated) map.animateCamera(update, 520) else map.moveCamera(update) + if (animated) map.animateCamera(update, 420) else map.moveCamera(update) return } - val centerLat = points.map { it.latitude }.average() val centerLon = points.map { it.longitude }.average() val latSpan = points.maxOf { it.latitude } - points.minOf { it.latitude } @@ -704,7 +737,7 @@ private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations else -> 15.2 } val update = CameraUpdateFactory.newLatLngZoom(LatLng(centerLat, centerLon), zoom) - if (animated) map.animateCamera(update, 520) else map.moveCamera(update) + if (animated) map.animateCamera(update, 420) else map.moveCamera(update) } private fun createTimeMarkerBitmap(label: String, darkTheme: Boolean, density: Float): Bitmap { @@ -734,7 +767,6 @@ private fun createTimeMarkerBitmap(label: String, darkTheme: Boolean, density: F val rect = RectF(0f, 0f, width.toFloat(), bodyHeight.toFloat()) canvas.drawRoundRect(rect, radius, radius, fill) canvas.drawRoundRect(rect, radius, radius, stroke) - val center = width / 2f val tail = Path().apply { moveTo(center - 5f * density, bodyHeight.toFloat() - 1f) @@ -743,7 +775,6 @@ private fun createTimeMarkerBitmap(label: String, darkTheme: Boolean, density: F close() } canvas.drawPath(tail, fill) - val baseline = bodyHeight / 2f - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f canvas.drawText(label, (width - textWidth) / 2f, baseline, textPaint) return bitmap From d8dbc62e642b2c887e205976f1f33ffb7de6eccd Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:26:36 -0400 Subject: [PATCH 24/29] Fix rendered building feature queries --- app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index c126b57..7b8345f 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -658,7 +658,7 @@ private fun renderedBuildingFeature( -14f to -14f, ) val features = offsets.asSequence() - .flatMap { (dx, dy) -> map.queryRenderedFeatures(PointF(center.x + dx, center.y + dy), layerIds).asSequence() } + .flatMap { (dx, dy) -> map.queryRenderedFeatures(PointF(center.x + dx, center.y + dy), *layerIds).asSequence() } .distinctBy { it.id() ?: it.toJson() } .toList() if (features.isEmpty()) return null From 64b1671b5d9ca3b1558631f6fde39106aab8227f Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:46:15 -0400 Subject: [PATCH 25/29] Match web map class and entrance markers --- .../gapwise/android/feature/map/MapScreen.kt | 227 ++++++++++++++---- 1 file changed, 180 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index 7b8345f..d82e2a6 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -2,8 +2,8 @@ package ca.gapwise.android.feature.map import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.DashPathEffect import android.graphics.Paint -import android.graphics.Path import android.graphics.PointF import android.graphics.RectF import androidx.compose.foundation.BorderStroke @@ -84,7 +84,7 @@ import kotlin.math.max private const val UTM_LAT = 43.55105 private const val UTM_LON = -79.66475 private const val CAMPUS_ZOOM = 16.0 -private const val BUILDING_ZOOM = 17.6 +private const val BUILDING_ZOOM = 17.25 private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron" private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark" private const val SELECTED_BUILDING_SOURCE = "gapwise-selected-building-source" @@ -104,9 +104,50 @@ private data class BuildingPoint( val latitude: Double, val longitude: Double, val kind: String = "Academic", - val mappedEntrances: Int = 1, ) +private data class EntrancePoint( + val id: String, + val buildingCode: String, + val label: String, + val latitude: Double, + val longitude: Double, + val kind: String = "entrance", + val accessibility: String = "unknown", +) + +/** + * Source-backed entrance points mirrored from gapwise/src/data/utm/entrances.geojson. + * Keep these coordinates exact: class labels and entrance badges intentionally share + * the same anchor semantics as the web map. + */ +private val EntrancePoints = listOf( + EntrancePoint("mn-13738201127", "MN", "Mapped entrance", 43.5513221, -79.6654141), + EntrancePoint("dh-13568164836", "DH", "Main entrance A", 43.5503162, -79.6659651), + EntrancePoint("dh-13568164837", "DH", "Main entrance B", 43.5505658, -79.6666995), + EntrancePoint("dh-13751172451", "DH", "Mapped entrance", 43.5505348, -79.6660506), + EntrancePoint("ib-2383650599", "IB", "Main entrance", 43.5516425, -79.6636318), + EntrancePoint("ib-2383653565", "IB", "Mapped entrance", 43.5514803, -79.6642634), + EntrancePoint("dv-1728238982", "DV", "Main entrance A", 43.5484051, -79.6617704), + EntrancePoint("dv-13568164844", "DV", "Accessible main entrance", 43.5494213, -79.6625598, accessibility = "accessible"), + EntrancePoint("dv-13736463909", "DV", "Accessible main entrance", 43.5477041, -79.6618689, accessibility = "accessible"), + EntrancePoint("dv-370306723", "DV", "Main entrance B", 43.5493975, -79.6623618), + EntrancePoint("cct-13568164845", "CCT", "Accessible entrance", 43.5495452, -79.6626873, accessibility = "accessible"), + EntrancePoint("hm-13731205434", "HM", "Main entrance", 43.5510334, -79.6630169), + EntrancePoint("kn-13568164841", "KN", "Accessible main entrance A", 43.5480958, -79.6635084, accessibility = "accessible"), + EntrancePoint("kn-1312579120", "KN", "Accessible main entrance B", 43.5479701, -79.6628687, accessibility = "accessible"), + EntrancePoint("kn-13568164842", "KN", "Accessible main entrance C", 43.5485709, -79.6632372, accessibility = "accessible"), + EntrancePoint("rawc-13568164832", "RAWC", "Main entrance", 43.5479331, -79.6606714), + EntrancePoint("xr-13568164843", "XR", "Main entrance", 43.5488462, -79.6637236), + EntrancePoint("xr-2105676602", "XR", "Mapped entrance", 43.5485377, -79.6639869), + EntrancePoint("hb-13738956113", "HB", "Main entrance A", 43.5497526, -79.6622362), + EntrancePoint("hb-13568164846", "HB", "Main entrance B", 43.5494997, -79.6623068), + EntrancePoint("ax-1728239148", "AX", "Mapped pedestrian approach", 43.5481895, -79.6643646, kind = "approach"), + EntrancePoint("dw-13767739551", "DW", "Mapped entrance", 43.5499078, -79.6661378), +) + +private val EntrancesByBuilding = EntrancePoints.groupBy { it.buildingCode } + private val BuildingPoints = listOf( BuildingPoint("MN", "Maanjiwe nendamowinan", 43.5513221, -79.6654141), BuildingPoint("DH", "Deerfield Hall", 43.5503162, -79.6659651), @@ -437,6 +478,7 @@ private fun MeetingMapCard(meeting: Meeting, onClose: () -> Unit, modifier: Modi @Composable private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifier: Modifier) { + val mappedEntrances = EntrancesByBuilding[building.code].orEmpty().size Surface( modifier = modifier, shape = RoundedCornerShape(9.dp), @@ -456,11 +498,11 @@ private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifi horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("${building.mappedEntrances} mapped ${if (building.mappedEntrances == 1) "entrance" else "entrances"}", fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + Text("$mappedEntrances mapped ${if (mappedEntrances == 1) "entrance" else "entrances"}", fontSize = 11.sp, fontWeight = FontWeight.SemiBold) Text("Partial coverage", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) } Text( - "${building.mappedEntrances} source-backed mapped ${if (building.mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped.", + "$mappedEntrances source-backed mapped ${if (mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped.", modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, @@ -533,10 +575,17 @@ private fun UtmMap( mapView.getMapAsync { map -> map.setOnMarkerClickListener { marker -> val snippet = marker.snippet.orEmpty() - if (snippet.startsWith("meeting:")) { - meetingSelection(snippet.removePrefix("meeting:")) - true - } else false + when { + snippet.startsWith("meeting:") -> { + meetingSelection(snippet.removePrefix("meeting:")) + true + } + snippet.startsWith("entrance:") -> { + buildingSelection(snippet.substringAfter("entrance:").substringBefore(':')) + true + } + else -> false + } } val listener = org.maplibre.android.maps.MapLibreMap.OnMapClickListener { latLng -> val style = map.style ?: return@OnMapClickListener false @@ -558,31 +607,44 @@ private fun UtmMap( } } - LaunchedEffect(destinations, mapView, darkTheme) { + LaunchedEffect(destinations, focusedBuilding?.code, focusedMeeting?.id, mapView, darkTheme) { mapView.getMapAsync { map -> map.getStyle { style -> map.removeAnnotations() val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() val routePoints = destinations.mapNotNull { meeting -> - meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> LatLng(point.latitude, point.longitude) } + meeting.buildingCode?.let(::primaryEntranceForBuilding)?.let { entrance -> + LatLng(entrance.latitude, entrance.longitude) + } ?: meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> + LatLng(point.latitude, point.longitude) + } } if (routePoints.size > 1 && routePoints.distinct().size > 1) { map.addPolyline(PolylineOptions().addAll(routePoints).color(accent).width(4f)) } + val grouped = destinations.groupBy { it.buildingCode } grouped.forEach { (code, buildingMeetings) -> - val point = code?.let(BuildingPoints::get) ?: return@forEach - buildingMeetings.sortedBy { it.startTime }.forEachIndexed { index, meeting -> - val latitudeOffset = index * 0.000075 + val buildingCode = code ?: return@forEach + val building = BuildingPoints[buildingCode] ?: return@forEach + val anchor = primaryEntranceForBuilding(buildingCode) + val anchorLat = anchor?.latitude ?: building.latitude + val anchorLon = anchor?.longitude ?: building.longitude + val ordered = buildingMeetings.sortedBy { it.startTime } + ordered.forEachIndexed { index, meeting -> + val stackFromBottom = ordered.lastIndex - index map.addMarker( MarkerOptions() - .position(LatLng(point.latitude + latitudeOffset, point.longitude)) + .position(LatLng(anchorLat, anchorLon)) + .anchor(0.5f, 1f) .icon( IconFactory.getInstance(context).fromBitmap( createTimeMarkerBitmap( label = formatTimeLabel(meeting.startTime), darkTheme = darkTheme, density = context.resources.displayMetrics.density, + stackFromBottom = stackFromBottom, + selected = meeting.id == focusedMeeting?.id, ), ), ) @@ -590,8 +652,30 @@ private fun UtmMap( ) } } - if (focusedBuilding == null && focusedMeeting == null) clearBuildingHighlight(style) - fitDayRoute(map, destinations, animated = true) + + val entranceBuildingCode = focusedMeeting?.buildingCode ?: focusedBuilding?.code + EntrancesByBuilding[entranceBuildingCode].orEmpty().forEach { entrance -> + map.addMarker( + MarkerOptions() + .position(LatLng(entrance.latitude, entrance.longitude)) + .anchor(0.5f, 0.5f) + .icon( + IconFactory.getInstance(context).fromBitmap( + createEntranceMarkerBitmap( + entrance = entrance, + darkTheme = darkTheme, + density = context.resources.displayMetrics.density, + ), + ), + ) + .snippet("entrance:${entrance.buildingCode}:${entrance.id}"), + ) + } + + if (focusedBuilding == null && focusedMeeting == null) { + clearBuildingHighlight(style) + fitDayRoute(map, destinations, animated = true) + } } } } @@ -604,7 +688,7 @@ private fun UtmMap( clearBuildingHighlight(style) return@getStyle } - map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), 420) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), 360) mapView.postDelayed({ map.getStyle { currentStyle -> val feature = renderedBuildingFeature( @@ -615,7 +699,7 @@ private fun UtmMap( ) if (feature != null) highlightFeature(currentStyle, feature, darkTheme) } - }, 440) + }, 380) } } } @@ -630,13 +714,15 @@ private fun UtmMap( LaunchedEffect(campusRequest, mapView) { if (campusRequest > 0) mapView.getMapAsync { map -> map.getStyle { style -> clearBuildingHighlight(style) } - map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 420) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 360) } } AndroidView(factory = { mapView }, modifier = modifier) } +private fun primaryEntranceForBuilding(code: String): EntrancePoint? = EntrancesByBuilding[code]?.firstOrNull() + private fun renderedBuildingFeature( map: org.maplibre.android.maps.MapLibreMap, style: Style, @@ -721,7 +807,7 @@ private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations val points = destinations.mapNotNull { meeting -> meeting.buildingCode?.let(BuildingPoints::get) } if (points.isEmpty()) { val update = CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM) - if (animated) map.animateCamera(update, 420) else map.moveCamera(update) + if (animated) map.animateCamera(update, 360) else map.moveCamera(update) return } val centerLat = points.map { it.latitude }.average() @@ -730,53 +816,100 @@ private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations val lonSpan = points.maxOf { it.longitude } - points.minOf { it.longitude } val spread = max(abs(latSpan), abs(lonSpan)) val zoom = when { - spread < 0.00025 -> 17.35 - spread < 0.001 -> 16.75 - spread < 0.0025 -> 16.15 - spread < 0.0045 -> 15.65 + spread < 0.00025 -> 17.2 + spread < 0.001 -> 16.7 + spread < 0.0025 -> 16.1 + spread < 0.0045 -> 15.6 else -> 15.2 } val update = CameraUpdateFactory.newLatLngZoom(LatLng(centerLat, centerLon), zoom) - if (animated) map.animateCamera(update, 420) else map.moveCamera(update) + if (animated) map.animateCamera(update, 360) else map.moveCamera(update) } -private fun createTimeMarkerBitmap(label: String, darkTheme: Boolean, density: Float): Bitmap { +private fun createTimeMarkerBitmap( + label: String, + darkTheme: Boolean, + density: Float, + stackFromBottom: Int, + selected: Boolean, +): Bitmap { val horizontalPadding = 10f * density - val verticalPadding = 6f * density - val tailHeight = 5f * density + val verticalPadding = 5f * density val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.WHITE - textSize = 11f * density + textSize = 10.5f * density typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) } val textWidth = textPaint.measureText(label) - val width = (textWidth + horizontalPadding * 2).toInt().coerceAtLeast((58f * density).toInt()) - val bodyHeight = (textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2).toInt() - val height = bodyHeight + tailHeight.toInt() + val bodyWidth = (textWidth + horizontalPadding * 2).coerceAtLeast(58f * density) + val bodyHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2 + val shadowPad = 3f * density + val verticalSlot = 29f * density + // Reserve one slot under the lowest class for the circular E/A/♿ entrance marker. + val bottomPadding = (30f + stackFromBottom * 29f) * density + val width = (bodyWidth + shadowPad * 2).toInt().coerceAtLeast(1) + val height = (bodyHeight + shadowPad * 2 + bottomPadding).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) + val left = shadowPad + val top = shadowPad + val rect = RectF(left, top, left + bodyWidth, top + bodyHeight) + val radius = 11f * density val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = if (darkTheme) android.graphics.Color.rgb(45, 157, 226) else android.graphics.Color.rgb(20, 107, 184) + color = if (darkTheme) android.graphics.Color.rgb(38, 145, 220) else android.graphics.Color.rgb(20, 107, 184) + setShadowLayer(2.5f * density, 0f, 1.5f * density, 0x66000000) } val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = 1.25f * density - color = if (darkTheme) android.graphics.Color.rgb(113, 198, 255) else android.graphics.Color.rgb(10, 79, 137) + strokeWidth = (if (selected) 2.2f else 1.4f) * density + color = if (selected) android.graphics.Color.WHITE + else if (darkTheme) android.graphics.Color.rgb(113, 198, 255) + else android.graphics.Color.rgb(10, 79, 137) } - val radius = 11f * density - val rect = RectF(0f, 0f, width.toFloat(), bodyHeight.toFloat()) canvas.drawRoundRect(rect, radius, radius, fill) canvas.drawRoundRect(rect, radius, radius, stroke) - val center = width / 2f - val tail = Path().apply { - moveTo(center - 5f * density, bodyHeight.toFloat() - 1f) - lineTo(center, height.toFloat()) - lineTo(center + 5f * density, bodyHeight.toFloat() - 1f) - close() + val baseline = top + bodyHeight / 2f - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f + canvas.drawText(label, left + (bodyWidth - textWidth) / 2f, baseline, textPaint) + return bitmap +} + +private fun createEntranceMarkerBitmap( + entrance: EntrancePoint, + darkTheme: Boolean, + density: Float, +): Bitmap { + val canvasSize = (38f * density).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val center = canvasSize / 2f + val radius = 14f * density + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (darkTheme) android.graphics.Color.rgb(11, 17, 26) else android.graphics.Color.WHITE + setShadowLayer(3f * density, 0f, 1.5f * density, 0x66000000) + } + val accent = if (darkTheme) android.graphics.Color.rgb(96, 165, 250) else android.graphics.Color.rgb(20, 107, 184) + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = accent + style = Paint.Style.STROKE + strokeWidth = 2f * density + if (entrance.kind == "approach") pathEffect = DashPathEffect(floatArrayOf(4f * density, 3f * density), 0f) + } + canvas.drawCircle(center, center, radius, fill) + canvas.drawCircle(center, center, radius, stroke) + + val markerText = when { + entrance.accessibility == "accessible" -> "♿" + entrance.kind == "approach" -> "A" + else -> "E" + } + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = accent + textSize = (if (markerText == "♿") 12f else 12.5f) * density + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + textAlign = Paint.Align.CENTER } - canvas.drawPath(tail, fill) - val baseline = bodyHeight / 2f - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f - canvas.drawText(label, (width - textWidth) / 2f, baseline, textPaint) + val baseline = center - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f + canvas.drawText(markerText, center, baseline, textPaint) return bitmap } From 7a83080b3f00ab3816ef7ac28bfe1d613259a9de Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 10:50:06 -0400 Subject: [PATCH 26/29] Fix MapLibre marker positioning --- .../java/ca/gapwise/android/feature/map/MapScreen.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index d82e2a6..50e367e 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -636,7 +636,6 @@ private fun UtmMap( map.addMarker( MarkerOptions() .position(LatLng(anchorLat, anchorLon)) - .anchor(0.5f, 1f) .icon( IconFactory.getInstance(context).fromBitmap( createTimeMarkerBitmap( @@ -658,7 +657,6 @@ private fun UtmMap( map.addMarker( MarkerOptions() .position(LatLng(entrance.latitude, entrance.longitude)) - .anchor(0.5f, 0.5f) .icon( IconFactory.getInstance(context).fromBitmap( createEntranceMarkerBitmap( @@ -844,9 +842,10 @@ private fun createTimeMarkerBitmap( val bodyWidth = (textWidth + horizontalPadding * 2).coerceAtLeast(58f * density) val bodyHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2 val shadowPad = 3f * density - val verticalSlot = 29f * density - // Reserve one slot under the lowest class for the circular E/A/♿ entrance marker. - val bottomPadding = (30f + stackFromBottom * 29f) * density + // MapLibre centers custom marker bitmaps on their geographic coordinate. Extra transparent + // space below the pill therefore produces a stable screen-space upward offset without + // inventing fake latitude offsets. One full slot remains below the lowest time for E/A/♿. + val bottomPadding = (64f + stackFromBottom * 58f) * density val width = (bodyWidth + shadowPad * 2).toInt().coerceAtLeast(1) val height = (bodyHeight + shadowPad * 2 + bottomPadding).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) From 9126794240b88a9e76dea5fee5a8e87f00d8fb67 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 11:21:37 -0400 Subject: [PATCH 27/29] Add canonical campus map API client --- .../android/feature/map/CampusMapApi.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt diff --git a/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt b/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt new file mode 100644 index 0000000..ba6cde1 --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt @@ -0,0 +1,173 @@ +package ca.gapwise.android.feature.map + +import ca.gapwise.android.core.persistence.RouteMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.maplibre.geojson.Feature +import java.net.HttpURLConnection +import java.net.URL + +internal data class CampusEntrance( + val id: String, + val buildingCode: String, + val label: String, + val longitude: Double, + val latitude: Double, + val kind: String, + val accessibility: String, +) + +internal data class CampusBuildingGeometry( + val code: String, + val name: String, + val category: String, + val longitude: Double, + val latitude: Double, + val footprint: Feature?, + val entrances: List, +) + +internal data class CampusMapSnapshot( + val dataVersion: String, + val buildings: Map, +) { + val footprintFeatures: List + get() = buildings.values.mapNotNull { it.footprint } +} + +internal data class CampusPathGeometry( + val status: String, + val coordinates: List>, +) + +internal data class CampusRoutePreferences( + val mode: RouteMode, + val walkingSpeedMps: Float, + val transitionBufferMinutes: Int, +) { + fun cacheKey(): String = "${mode.name}|$walkingSpeedMps|$transitionBufferMinutes" +} + +internal object CampusMapApi { + private const val MAP_URL = "https://gapwise.ca/api/utm-map" + private const val PATH_URL = "https://gapwise.ca/api/utm-path" + private const val CONNECT_TIMEOUT_MS = 5_000 + private const val READ_TIMEOUT_MS = 8_000 + + suspend fun fetchMap(): CampusMapSnapshot = withContext(Dispatchers.IO) { + parseMap(request(MAP_URL, "GET", null)) + } + + suspend fun fetchPath( + from: String, + to: String, + preferences: CampusRoutePreferences, + ): CampusPathGeometry = withContext(Dispatchers.IO) { + if (from == to) return@withContext CampusPathGeometry("same-building", emptyList()) + val body = JSONObject() + .put("from", from) + .put("to", to) + .put( + "preferences", + JSONObject() + .put("mode", preferences.mode.apiValue()) + .put("walkingSpeedMps", preferences.walkingSpeedMps.toDouble()) + .put("transitionBufferMinutes", preferences.transitionBufferMinutes), + ) + .toString() + parsePath(request(PATH_URL, "POST", body)) + } + + private fun request(url: String, method: String, body: String?): String { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.requestMethod = method + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("User-Agent", "Gapwise-Android/0.1") + if (body != null) { + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8") + connection.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(body) } + } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + check(status in 200..299) { "Gapwise campus API returned HTTP $status." } + return text + } finally { + connection.disconnect() + } + } + + private fun parseMap(raw: String): CampusMapSnapshot { + val root = JSONObject(raw) + val buildingsJson = root.getJSONArray("buildings") + val buildings = LinkedHashMap(buildingsJson.length()) + for (index in 0 until buildingsJson.length()) { + val item = buildingsJson.getJSONObject(index) + val code = item.getString("code").uppercase() + val navigation = item.optJSONArray("navigationPoint") + if (navigation == null || navigation.length() < 2) continue + val entrancesJson = item.optJSONArray("entrances") ?: JSONArray() + val entrances = buildList { + for (entranceIndex in 0 until entrancesJson.length()) { + val entrance = entrancesJson.getJSONObject(entranceIndex) + val point = entrance.getJSONArray("coordinates") + add( + CampusEntrance( + id = entrance.getString("id"), + buildingCode = code, + label = entrance.optString("label", "Mapped entrance"), + longitude = point.getDouble(0), + latitude = point.getDouble(1), + kind = entrance.optString("kind", "entrance"), + accessibility = entrance.optString("accessibility", "unknown"), + ), + ) + } + } + val footprint = item.optJSONObject("footprint")?.let { objectJson -> + runCatching { Feature.fromJson(objectJson.toString()) }.getOrNull() + } + buildings[code] = CampusBuildingGeometry( + code = code, + name = item.getString("name"), + category = item.optString("category", "academic"), + longitude = navigation.getDouble(0), + latitude = navigation.getDouble(1), + footprint = footprint, + entrances = entrances, + ) + } + check(buildings.isNotEmpty()) { "Gapwise campus API returned no mapped buildings." } + return CampusMapSnapshot( + dataVersion = root.optString("dataVersion", "unknown"), + buildings = buildings, + ) + } + + private fun parsePath(raw: String): CampusPathGeometry { + val root = JSONObject(raw) + val coordinatesJson = root.optJSONArray("displayCoordinates") ?: JSONArray() + val coordinates = buildList { + for (index in 0 until coordinatesJson.length()) { + val point = coordinatesJson.getJSONArray(index) + if (point.length() >= 2) add(point.getDouble(0) to point.getDouble(1)) + } + } + return CampusPathGeometry( + status = root.optString("status", if (coordinates.size >= 2) "routed" else "unavailable"), + coordinates = coordinates, + ) + } +} + +private fun RouteMode.apiValue(): String = when (this) { + RouteMode.FASTEST -> "fastest" + RouteMode.PREFER_INDOOR -> "prefer-indoor" + RouteMode.STEP_FREE -> "step-free" +} From ba22718aa2ec08111054bc1be0c141fcbdb61139 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 11:24:40 -0400 Subject: [PATCH 28/29] Use canonical web map data and paths --- .../gapwise/android/feature/map/MapScreen.kt | 608 ++++++++++-------- 1 file changed, 342 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt index 50e367e..e9bb3d2 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/MapScreen.kt @@ -4,8 +4,8 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.DashPathEffect import android.graphics.Paint -import android.graphics.PointF import android.graphics.RectF +import android.view.MotionEvent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -46,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -58,7 +59,12 @@ import ca.gapwise.android.core.model.Campus import ca.gapwise.android.core.model.LocationType import ca.gapwise.android.core.model.Meeting import ca.gapwise.android.core.model.Term +import ca.gapwise.android.core.persistence.AppPreferences +import ca.gapwise.android.core.persistence.RouteMode import ca.gapwise.android.feature.settings.RoutePreferencesSheet +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import org.maplibre.android.MapLibre import org.maplibre.android.annotations.IconFactory import org.maplibre.android.annotations.MarkerOptions @@ -68,17 +74,16 @@ import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style -import org.maplibre.android.style.layers.FillExtrusionLayer import org.maplibre.android.style.layers.FillLayer import org.maplibre.android.style.layers.PropertyFactory.fillColor import org.maplibre.android.style.layers.PropertyFactory.fillOpacity import org.maplibre.android.style.layers.PropertyFactory.fillOutlineColor import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection import java.time.DayOfWeek import java.time.LocalDate import kotlin.math.abs -import kotlin.math.hypot import kotlin.math.max private const val UTM_LAT = 43.55105 @@ -87,6 +92,8 @@ private const val CAMPUS_ZOOM = 16.0 private const val BUILDING_ZOOM = 17.25 private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron" private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark" +private const val BUILDING_HIT_SOURCE = "gapwise-canonical-building-source" +private const val BUILDING_HIT_FILL = "gapwise-canonical-building-hit" private const val SELECTED_BUILDING_SOURCE = "gapwise-selected-building-source" private const val SELECTED_BUILDING_FILL = "gapwise-selected-building-fill" @@ -98,76 +105,72 @@ private val BaseDays = listOf( DayOfWeek.FRIDAY, ) -private data class BuildingPoint( - val code: String, - val name: String, - val latitude: Double, - val longitude: Double, - val kind: String = "Academic", +private val FallbackEntrances = listOf( + CampusEntrance("mn-13738201127", "MN", "Mapped entrance", -79.6654141, 43.5513221, "entrance", "unknown"), + CampusEntrance("dh-13568164836", "DH", "Main entrance A", -79.6659651, 43.5503162, "entrance", "unknown"), + CampusEntrance("dh-13568164837", "DH", "Main entrance B", -79.6666995, 43.5505658, "entrance", "unknown"), + CampusEntrance("dh-13751172451", "DH", "Mapped entrance", -79.6660506, 43.5505348, "entrance", "unknown"), + CampusEntrance("ib-2383650599", "IB", "Main entrance", -79.6636318, 43.5516425, "entrance", "unknown"), + CampusEntrance("ib-2383653565", "IB", "Mapped entrance", -79.6642634, 43.5514803, "entrance", "unknown"), + CampusEntrance("dv-1728238982", "DV", "Main entrance A", -79.6617704, 43.5484051, "entrance", "unknown"), + CampusEntrance("dv-13568164844", "DV", "Accessible main entrance", -79.6625598, 43.5494213, "entrance", "accessible"), + CampusEntrance("dv-13736463909", "DV", "Accessible main entrance", -79.6618689, 43.5477041, "entrance", "accessible"), + CampusEntrance("dv-370306723", "DV", "Main entrance B", -79.6623618, 43.5493975, "entrance", "unknown"), + CampusEntrance("cct-13568164845", "CCT", "Accessible entrance", -79.6626873, 43.5495452, "entrance", "accessible"), + CampusEntrance("hm-13731205434", "HM", "Main entrance", -79.6630169, 43.5510334, "entrance", "unknown"), + CampusEntrance("kn-13568164841", "KN", "Accessible main entrance A", -79.6635084, 43.5480958, "entrance", "accessible"), + CampusEntrance("kn-1312579120", "KN", "Accessible main entrance B", -79.6628687, 43.5479701, "entrance", "accessible"), + CampusEntrance("kn-13568164842", "KN", "Accessible main entrance C", -79.6632372, 43.5485709, "entrance", "accessible"), + CampusEntrance("rawc-13568164832", "RAWC", "Main entrance", -79.6606714, 43.5479331, "entrance", "unknown"), + CampusEntrance("xr-13568164843", "XR", "Main entrance", -79.6637236, 43.5488462, "entrance", "unknown"), + CampusEntrance("xr-2105676602", "XR", "Mapped entrance", -79.6639869, 43.5485377, "entrance", "unknown"), + CampusEntrance("hb-13738956113", "HB", "Main entrance A", -79.6622362, 43.5497526, "entrance", "unknown"), + CampusEntrance("hb-13568164846", "HB", "Main entrance B", -79.6623068, 43.5494997, "entrance", "unknown"), + CampusEntrance("ax-1728239148", "AX", "Mapped pedestrian approach", -79.6643646, 43.5481895, "approach", "unknown"), + CampusEntrance("dw-13767739551", "DW", "Mapped entrance", -79.6661378, 43.5499078, "entrance", "unknown"), ) -private data class EntrancePoint( - val id: String, - val buildingCode: String, - val label: String, - val latitude: Double, - val longitude: Double, - val kind: String = "entrance", - val accessibility: String = "unknown", -) - -/** - * Source-backed entrance points mirrored from gapwise/src/data/utm/entrances.geojson. - * Keep these coordinates exact: class labels and entrance badges intentionally share - * the same anchor semantics as the web map. - */ -private val EntrancePoints = listOf( - EntrancePoint("mn-13738201127", "MN", "Mapped entrance", 43.5513221, -79.6654141), - EntrancePoint("dh-13568164836", "DH", "Main entrance A", 43.5503162, -79.6659651), - EntrancePoint("dh-13568164837", "DH", "Main entrance B", 43.5505658, -79.6666995), - EntrancePoint("dh-13751172451", "DH", "Mapped entrance", 43.5505348, -79.6660506), - EntrancePoint("ib-2383650599", "IB", "Main entrance", 43.5516425, -79.6636318), - EntrancePoint("ib-2383653565", "IB", "Mapped entrance", 43.5514803, -79.6642634), - EntrancePoint("dv-1728238982", "DV", "Main entrance A", 43.5484051, -79.6617704), - EntrancePoint("dv-13568164844", "DV", "Accessible main entrance", 43.5494213, -79.6625598, accessibility = "accessible"), - EntrancePoint("dv-13736463909", "DV", "Accessible main entrance", 43.5477041, -79.6618689, accessibility = "accessible"), - EntrancePoint("dv-370306723", "DV", "Main entrance B", 43.5493975, -79.6623618), - EntrancePoint("cct-13568164845", "CCT", "Accessible entrance", 43.5495452, -79.6626873, accessibility = "accessible"), - EntrancePoint("hm-13731205434", "HM", "Main entrance", 43.5510334, -79.6630169), - EntrancePoint("kn-13568164841", "KN", "Accessible main entrance A", 43.5480958, -79.6635084, accessibility = "accessible"), - EntrancePoint("kn-1312579120", "KN", "Accessible main entrance B", 43.5479701, -79.6628687, accessibility = "accessible"), - EntrancePoint("kn-13568164842", "KN", "Accessible main entrance C", 43.5485709, -79.6632372, accessibility = "accessible"), - EntrancePoint("rawc-13568164832", "RAWC", "Main entrance", 43.5479331, -79.6606714), - EntrancePoint("xr-13568164843", "XR", "Main entrance", 43.5488462, -79.6637236), - EntrancePoint("xr-2105676602", "XR", "Mapped entrance", 43.5485377, -79.6639869), - EntrancePoint("hb-13738956113", "HB", "Main entrance A", 43.5497526, -79.6622362), - EntrancePoint("hb-13568164846", "HB", "Main entrance B", 43.5494997, -79.6623068), - EntrancePoint("ax-1728239148", "AX", "Mapped pedestrian approach", 43.5481895, -79.6643646, kind = "approach"), - EntrancePoint("dw-13767739551", "DW", "Mapped entrance", 43.5499078, -79.6661378), -) - -private val EntrancesByBuilding = EntrancePoints.groupBy { it.buildingCode } - -private val BuildingPoints = listOf( - BuildingPoint("MN", "Maanjiwe nendamowinan", 43.5513221, -79.6654141), - BuildingPoint("DH", "Deerfield Hall", 43.5503162, -79.6659651), - BuildingPoint("IB", "Instructional Centre", 43.5516425, -79.6636318), - BuildingPoint("DV", "William G. Davis Building", 43.5484051, -79.6617704), - BuildingPoint("CCT", "Communication, Culture and Technology Building", 43.5495452, -79.6626873), - BuildingPoint("HM", "Hazel McCallion Academic Learning Centre", 43.5510334, -79.6630169), - BuildingPoint("KN", "Kaneff Centre", 43.5480958, -79.6635084), - BuildingPoint("RAWC", "Recreation, Athletics and Wellness Centre", 43.5479331, -79.6606714), - BuildingPoint("XR", "Student Centre", 43.5488462, -79.6637236), - BuildingPoint("HB", "Terrence Donnelly Health Sciences Complex", 43.5497526, -79.6622362), - BuildingPoint("AX", "Academic Annex", 43.5481895, -79.6643646), - BuildingPoint("DW", "Erindale Studio Theatre", 43.5499078, -79.6661378), -).associateBy { it.code } +private val FallbackBuildings: Map = listOf( + Triple("MN", "Maanjiwe nendamowinan", -79.6654141 to 43.5513221), + Triple("DH", "Deerfield Hall", -79.6659651 to 43.5503162), + Triple("IB", "Instructional Centre", -79.6636318 to 43.5516425), + Triple("DV", "William G. Davis Building", -79.6617704 to 43.5484051), + Triple("CCT", "Communication, Culture and Technology Building", -79.6626873 to 43.5495452), + Triple("HM", "Hazel McCallion Academic Learning Centre", -79.6630169 to 43.5510334), + Triple("KN", "Kaneff Centre", -79.6635084 to 43.5480958), + Triple("RAWC", "Recreation, Athletics and Wellness Centre", -79.6606714 to 43.5479331), + Triple("XR", "Student Centre", -79.6637236 to 43.5488462), + Triple("HB", "Terrence Donnelly Health Sciences Complex", -79.6622362 to 43.5497526), + Triple("AX", "Academic Annex", -79.6643646 to 43.5481895), + Triple("DW", "Erindale Studio Theatre", -79.6661378 to 43.5499078), +).associate { (code, name, point) -> + code to CampusBuildingGeometry( + code = code, + name = name, + category = "academic", + longitude = point.first, + latitude = point.second, + footprint = null, + entrances = FallbackEntrances.filter { it.buildingCode == code }, + ) +} @Composable fun MapScreen( meetings: List, darkTheme: Boolean, ) { + val context = LocalContext.current + val preferencesStore = remember { AppPreferences(context.applicationContext) } + var routePreferences by remember { + mutableStateOf( + CampusRoutePreferences( + preferencesStore.routeMode(), + preferencesStore.walkingSpeedMps(), + preferencesStore.transitionBufferMinutes(), + ), + ) + } val availableTerms = remember(meetings) { meetings.map { it.term }.distinct() } var selectedTerm by remember(meetings) { mutableStateOf(availableTerms.firstOrNull { it == currentTerm() } ?: availableTerms.firstOrNull() ?: Term.FALL) @@ -188,6 +191,16 @@ fun MapScreen( val dayMeetings = termMeetings.filter { it.weekday == weekday }.sortedBy { it.startTime } var optionsOpen by remember { mutableStateOf(false) } + LaunchedEffect(optionsOpen) { + if (!optionsOpen) { + routePreferences = CampusRoutePreferences( + preferencesStore.routeMode(), + preferencesStore.walkingSpeedMps(), + preferencesStore.transitionBufferMinutes(), + ) + } + } + LazyColumn( contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -270,7 +283,11 @@ fun MapScreen( } item { - CampusMapCard(meetings = dayMeetings, darkTheme = darkTheme) + CampusMapCard( + meetings = dayMeetings, + darkTheme = darkTheme, + routePreferences = routePreferences, + ) } } @@ -281,27 +298,55 @@ fun MapScreen( private fun CampusMapCard( meetings: List, darkTheme: Boolean, + routePreferences: CampusRoutePreferences, ) { - val destinations = remember(meetings) { + var snapshot by remember { mutableStateOf(null) } + val buildings = snapshot?.buildings ?: FallbackBuildings + val destinations = remember(meetings, buildings) { meetings .filter { it.campus == Campus.UTM && it.locationType == LocationType.PHYSICAL } - .filter { it.buildingCode != null && BuildingPoints.containsKey(it.buildingCode) } + .filter { it.buildingCode != null && buildings.containsKey(it.buildingCode) } } + var paths by remember { mutableStateOf>(emptyMap()) } var query by remember { mutableStateOf("") } var selectedCode by remember { mutableStateOf(null) } var selectedMeetingId by remember { mutableStateOf(null) } var fitRequest by remember { mutableIntStateOf(0) } var campusRequest by remember { mutableIntStateOf(0) } - val results = remember(query) { + + LaunchedEffect(Unit) { + runCatching { CampusMapApi.fetchMap() }.onSuccess { snapshot = it } + } + + val routeKey = remember(destinations, routePreferences) { + destinations.joinToString("|") { it.id } + "@" + routePreferences.cacheKey() + } + LaunchedEffect(routeKey) { + val fetched = coroutineScope { + destinations.zipWithNext().mapNotNull { (from, to) -> + val fromCode = from.buildingCode ?: return@mapNotNull null + val toCode = to.buildingCode ?: return@mapNotNull null + val key = segmentKey(from, to) + async { + key to runCatching { + CampusMapApi.fetchPath(fromCode, toCode, routePreferences) + }.getOrNull() + } + }.awaitAll() + } + paths = fetched.mapNotNull { (key, value) -> value?.let { key to it } }.toMap() + } + + val results = remember(query, buildings) { val normalized = query.trim().lowercase() val compact = normalized.replace(" ", "") if (normalized.isBlank()) emptyList() - else BuildingPoints.values.filter { point -> - val code = point.code.lowercase() - code.contains(normalized) || point.name.lowercase().contains(normalized) || compact.startsWith(code) + else buildings.values.filter { building -> + val code = building.code.lowercase() + code.contains(normalized) || building.name.lowercase().contains(normalized) || compact.startsWith(code) }.take(8) } - val selected = selectedCode?.let(BuildingPoints::get) + val selected = selectedCode?.let(buildings::get) val selectedMeeting = selectedMeetingId?.let { id -> destinations.firstOrNull { it.id == id } } Surface( @@ -314,8 +359,10 @@ private fun CampusMapCard( Box(modifier = Modifier.fillMaxSize()) { UtmMap( destinations = destinations, + buildings = buildings, + paths = paths, darkTheme = darkTheme, - focusedBuilding = selected, + focusedBuildingCode = selected?.code, focusedMeeting = selectedMeeting, fitRequest = fitRequest, campusRequest = campusRequest, @@ -435,6 +482,7 @@ private fun CampusMapCard( if (selectedMeeting != null) { MeetingMapCard( meeting = selectedMeeting, + building = selectedMeeting.buildingCode?.let(buildings::get), onClose = { selectedMeetingId = null }, modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(10.dp), ) @@ -450,35 +498,38 @@ private fun CampusMapCard( } @Composable -private fun MeetingMapCard(meeting: Meeting, onClose: () -> Unit, modifier: Modifier) { - val point = meeting.buildingCode?.let(BuildingPoints::get) +private fun MeetingMapCard( + meeting: Meeting, + building: CampusBuildingGeometry?, + onClose: () -> Unit, + modifier: Modifier, +) { Surface( modifier = modifier, shape = RoundedCornerShape(9.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Column(modifier = Modifier.weight(1f)) { - WebEyebrow("${meeting.buildingCode ?: "UTM"} · UTM") - Text(point?.name ?: meeting.locationLabel, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Text( - "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", - modifier = Modifier.padding(top = 5.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 11.sp, - ) - } - CloseMapCardButton("Close class details", onClose) + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + WebEyebrow("${meeting.buildingCode ?: "UTM"} · UTM") + Text(building?.name ?: meeting.locationLabel, modifier = Modifier.padding(top = 4.dp), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Text( + "${formatTimeLabel(meeting.startTime)} · ${meeting.courseCode} · ${meeting.locationLabel}", + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + ) } + CloseMapCardButton("Close class details", onClose) } } } @Composable -private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifier: Modifier) { - val mappedEntrances = EntrancesByBuilding[building.code].orEmpty().size +private fun BuildingMapCard(building: CampusBuildingGeometry, onClose: () -> Unit, modifier: Modifier) { + val mappedEntrances = building.entrances.count { it.kind == "entrance" } + val approaches = building.entrances.count { it.kind == "approach" } Surface( modifier = modifier, shape = RoundedCornerShape(9.dp), @@ -488,7 +539,7 @@ private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifi Column(modifier = Modifier.padding(12.dp)) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Column(modifier = Modifier.weight(1f)) { - WebEyebrow("${building.code} · ${building.kind}") + WebEyebrow("${building.code} · ${building.category}") Text(building.name, modifier = Modifier.padding(top = 5.dp), fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } CloseMapCardButton("Close building details", onClose) @@ -498,11 +549,20 @@ private fun BuildingMapCard(building: BuildingPoint, onClose: () -> Unit, modifi horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("$mappedEntrances mapped ${if (mappedEntrances == 1) "entrance" else "entrances"}", fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + Text( + when { + mappedEntrances > 0 -> "$mappedEntrances mapped ${if (mappedEntrances == 1) "entrance" else "entrances"}" + approaches > 0 -> "$approaches mapped ${if (approaches == 1) "approach" else "approaches"}" + else -> "No mapped entrances" + }, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) Text("Partial coverage", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp) } Text( - "$mappedEntrances source-backed mapped ${if (mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped.", + if (mappedEntrances > 0) "$mappedEntrances source-backed mapped ${if (mappedEntrances == 1) "entrance is" else "entrances are"} known. Other entrances may be missing. Indoor room paths are not currently mapped." + else "Physical entrance coverage is not established for this building. Indoor room paths are not currently mapped.", modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, @@ -539,8 +599,10 @@ private fun MapControl(icon: androidx.compose.ui.graphics.vector.ImageVector, on @Composable private fun UtmMap( destinations: List, + buildings: Map, + paths: Map, darkTheme: Boolean, - focusedBuilding: BuildingPoint?, + focusedBuildingCode: String?, focusedMeeting: Meeting?, fitRequest: Int, campusRequest: Int, @@ -548,10 +610,12 @@ private fun UtmMap( onBuildingSelected: (String) -> Unit, modifier: Modifier, ) { - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current val styleUrl = if (darkTheme) DARK_STYLE else LIGHT_STYLE val meetingSelection by rememberUpdatedState(onMeetingSelected) val buildingSelection by rememberUpdatedState(onBuildingSelected) + var styleEpoch by remember { mutableIntStateOf(0) } + var lastAutoFitKey by remember { mutableStateOf(null) } val mapView = remember(context, styleUrl) { MapLibre.getInstance(context) @@ -559,6 +623,16 @@ private fun UtmMap( onCreate(null) onStart() onResume() + addOnDidFinishLoadingStyleListener { styleEpoch += 1 } + setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> + view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + view.parent?.requestDisallowInterceptTouchEvent(false) + } + false + } getMapAsync { map -> map.uiSettings.setLogoEnabled(false) map.uiSettings.setAttributionEnabled(true) @@ -570,6 +644,8 @@ private fun UtmMap( } } + LaunchedEffect(mapView) { lastAutoFitKey = null } + DisposableEffect(mapView) { var mapClickListener: org.maplibre.android.maps.MapLibreMap.OnMapClickListener? = null mapView.getMapAsync { map -> @@ -589,11 +665,17 @@ private fun UtmMap( } val listener = org.maplibre.android.maps.MapLibreMap.OnMapClickListener { latLng -> val style = map.style ?: return@OnMapClickListener false - val feature = renderedBuildingFeature(map, style, latLng, preferredCode = null) - ?: return@OnMapClickListener false - val building = buildingForFeature(feature, latLng) ?: return@OnMapClickListener false - highlightFeature(style, feature, darkTheme) - buildingSelection(building.code) + if (style.getLayer(BUILDING_HIT_FILL) == null) return@OnMapClickListener false + val point = map.projection.toScreenLocation(latLng) + val codes = map.queryRenderedFeatures(point, BUILDING_HIT_FILL) + .mapNotNull { feature -> + runCatching { + if (feature.hasProperty("buildingCode")) feature.getStringProperty("buildingCode") else null + }.getOrNull() + } + .distinct() + val code = codes.singleOrNull() ?: return@OnMapClickListener false + buildingSelection(code) true } map.addOnMapClickListener(listener) @@ -607,190 +689,182 @@ private fun UtmMap( } } - LaunchedEffect(destinations, focusedBuilding?.code, focusedMeeting?.id, mapView, darkTheme) { + val contentKey = remember(destinations) { destinations.joinToString("|") { it.id } } + LaunchedEffect(destinations, buildings, paths, focusedMeeting?.id, focusedBuildingCode, styleEpoch, darkTheme, mapView) { + if (styleEpoch <= 0) return@LaunchedEffect mapView.getMapAsync { map -> - map.getStyle { style -> - map.removeAnnotations() - val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() - val routePoints = destinations.mapNotNull { meeting -> - meeting.buildingCode?.let(::primaryEntranceForBuilding)?.let { entrance -> - LatLng(entrance.latitude, entrance.longitude) - } ?: meeting.buildingCode?.let(BuildingPoints::get)?.let { point -> - LatLng(point.latitude, point.longitude) - } - } - if (routePoints.size > 1 && routePoints.distinct().size > 1) { - map.addPolyline(PolylineOptions().addAll(routePoints).color(accent).width(4f)) - } - - val grouped = destinations.groupBy { it.buildingCode } - grouped.forEach { (code, buildingMeetings) -> - val buildingCode = code ?: return@forEach - val building = BuildingPoints[buildingCode] ?: return@forEach - val anchor = primaryEntranceForBuilding(buildingCode) - val anchorLat = anchor?.latitude ?: building.latitude - val anchorLon = anchor?.longitude ?: building.longitude - val ordered = buildingMeetings.sortedBy { it.startTime } - ordered.forEachIndexed { index, meeting -> - val stackFromBottom = ordered.lastIndex - index - map.addMarker( - MarkerOptions() - .position(LatLng(anchorLat, anchorLon)) - .icon( - IconFactory.getInstance(context).fromBitmap( - createTimeMarkerBitmap( - label = formatTimeLabel(meeting.startTime), - darkTheme = darkTheme, - density = context.resources.displayMetrics.density, - stackFromBottom = stackFromBottom, - selected = meeting.id == focusedMeeting?.id, - ), - ), - ) - .snippet("meeting:${meeting.id}"), - ) - } - } - - val entranceBuildingCode = focusedMeeting?.buildingCode ?: focusedBuilding?.code - EntrancesByBuilding[entranceBuildingCode].orEmpty().forEach { entrance -> - map.addMarker( - MarkerOptions() - .position(LatLng(entrance.latitude, entrance.longitude)) - .icon( - IconFactory.getInstance(context).fromBitmap( - createEntranceMarkerBitmap( - entrance = entrance, - darkTheme = darkTheme, - density = context.resources.displayMetrics.density, - ), - ), - ) - .snippet("entrance:${entrance.buildingCode}:${entrance.id}"), - ) - } - - if (focusedBuilding == null && focusedMeeting == null) { - clearBuildingHighlight(style) - fitDayRoute(map, destinations, animated = true) - } + val style = map.style ?: return@getMapAsync + ensureCanonicalBuildingLayer(style, buildings.values.mapNotNull { it.footprint }) + syncSelectedBuildingHighlight(style, focusedBuildingCode, buildings, darkTheme) + renderDayOverlays( + map = map, + destinations = destinations, + buildings = buildings, + paths = paths, + darkTheme = darkTheme, + selectedMeetingId = focusedMeeting?.id, + context = context, + ) + if (contentKey != lastAutoFitKey) { + lastAutoFitKey = contentKey + fitDayRoute(map, destinations, buildings, animated = true) } } } - LaunchedEffect(focusedBuilding?.code, focusedMeeting?.id, mapView, darkTheme) { + LaunchedEffect(focusedBuildingCode, focusedMeeting?.id, buildings, styleEpoch, mapView) { + if (styleEpoch <= 0) return@LaunchedEffect + val code = focusedMeeting?.buildingCode ?: focusedBuildingCode ?: return@LaunchedEffect + val building = buildings[code] ?: return@LaunchedEffect mapView.getMapAsync { map -> - val point = focusedMeeting?.buildingCode?.let(BuildingPoints::get) ?: focusedBuilding - map.getStyle { style -> - if (point == null) { - clearBuildingHighlight(style) - return@getStyle - } - map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(point.latitude, point.longitude), BUILDING_ZOOM), 360) - mapView.postDelayed({ - map.getStyle { currentStyle -> - val feature = renderedBuildingFeature( - map, - currentStyle, - LatLng(point.latitude, point.longitude), - preferredCode = point.code, - ) - if (feature != null) highlightFeature(currentStyle, feature, darkTheme) - } - }, 380) - } + map.animateCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(building.latitude, building.longitude), BUILDING_ZOOM), + 340, + ) } } - LaunchedEffect(fitRequest, mapView) { + LaunchedEffect(fitRequest, buildings, mapView) { if (fitRequest > 0) mapView.getMapAsync { map -> - map.getStyle { style -> clearBuildingHighlight(style) } - fitDayRoute(map, destinations, animated = true) + fitDayRoute(map, destinations, buildings, animated = true) } } LaunchedEffect(campusRequest, mapView) { if (campusRequest > 0) mapView.getMapAsync { map -> - map.getStyle { style -> clearBuildingHighlight(style) } - map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 360) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 340) } } AndroidView(factory = { mapView }, modifier = modifier) } -private fun primaryEntranceForBuilding(code: String): EntrancePoint? = EntrancesByBuilding[code]?.firstOrNull() - -private fun renderedBuildingFeature( +private fun renderDayOverlays( map: org.maplibre.android.maps.MapLibreMap, - style: Style, - coordinate: LatLng, - preferredCode: String?, -): Feature? { - val layerIds = buildingLayerIds(style) - if (layerIds.isEmpty()) return null - val center = map.projection.toScreenLocation(coordinate) - val offsets = listOf( - 0f to 0f, - 8f to 0f, - -8f to 0f, - 0f to 8f, - 0f to -8f, - 14f to 14f, - -14f to 14f, - 14f to -14f, - -14f to -14f, - ) - val features = offsets.asSequence() - .flatMap { (dx, dy) -> map.queryRenderedFeatures(PointF(center.x + dx, center.y + dy), *layerIds).asSequence() } - .distinctBy { it.id() ?: it.toJson() } - .toList() - if (features.isEmpty()) return null - if (preferredCode != null) { - val building = BuildingPoints[preferredCode] - val match = features.firstOrNull { featureMatchesBuilding(it, building) } - if (match != null) return match - } - return features.firstOrNull() -} + destinations: List, + buildings: Map, + paths: Map, + darkTheme: Boolean, + selectedMeetingId: String?, + context: android.content.Context, +) { + map.removeAnnotations() + val accent = if (darkTheme) 0xFF60A5FA.toInt() else 0xFF146BB8.toInt() + + destinations.zipWithNext().forEach { (from, to) -> + val path = paths[segmentKey(from, to)] ?: return@forEach + if (path.status != "routed" || path.coordinates.size < 2) return@forEach + map.addPolyline( + PolylineOptions() + .addAll(path.coordinates.map { (longitude, latitude) -> LatLng(latitude, longitude) }) + .color(accent) + .width(4f), + ) + } -private fun buildingLayerIds(style: Style): Array = style.layers.mapNotNull { layer -> - when (layer) { - is FillLayer -> if (layer.sourceLayer == "building") layer.id else null - is FillExtrusionLayer -> if (layer.sourceLayer == "building") layer.id else null - else -> null + val dayBuildingCodes = destinations.mapNotNull { it.buildingCode }.distinct() + dayBuildingCodes.flatMap { code -> buildings[code]?.entrances.orEmpty() }.forEach { entrance -> + map.addMarker( + MarkerOptions() + .position(LatLng(entrance.latitude, entrance.longitude)) + .icon( + IconFactory.getInstance(context).fromBitmap( + createEntranceMarkerBitmap( + entrance = entrance, + darkTheme = darkTheme, + density = context.resources.displayMetrics.density, + ), + ), + ) + .snippet("entrance:${entrance.buildingCode}:${entrance.id}"), + ) } -}.toTypedArray() -private fun featureMatchesBuilding(feature: Feature, building: BuildingPoint?): Boolean { - if (building == null) return false - val candidates = listOf("ref", "name", "name:en", "short_name") - .mapNotNull { key -> - runCatching { if (feature.hasProperty(key)) feature.getStringProperty(key) else null }.getOrNull() + val grouped = destinations.withIndex().groupBy { it.value.buildingCode } + grouped.forEach { (code, indexedMeetings) -> + val building = code?.let(buildings::get) ?: return@forEach + val ordered = indexedMeetings.sortedBy { it.value.startTime } + ordered.forEachIndexed { stackIndex, indexedMeeting -> + val meeting = indexedMeeting.value + val coordinate = meetingAnchor(indexedMeeting.index, destinations, paths, building) + map.addMarker( + MarkerOptions() + .position(coordinate) + .icon( + IconFactory.getInstance(context).fromBitmap( + createTimeMarkerBitmap( + label = formatTimeLabel(meeting.startTime), + darkTheme = darkTheme, + density = context.resources.displayMetrics.density, + stackFromBottom = ordered.lastIndex - stackIndex, + selected = meeting.id == selectedMeetingId, + ), + ), + ) + .snippet("meeting:${meeting.id}"), + ) } - .map { it.trim().lowercase() } - val code = building.code.lowercase() - val name = building.name.lowercase() - return candidates.any { it == code || it == name || it.contains(name) } + } } -private fun buildingForFeature(feature: Feature, tap: LatLng): BuildingPoint? { - BuildingPoints.values.firstOrNull { featureMatchesBuilding(feature, it) }?.let { return it } - val nearest = BuildingPoints.values.minByOrNull { point -> - hypot(point.latitude - tap.latitude, (point.longitude - tap.longitude) * 0.73) - } ?: return null - val distance = hypot(nearest.latitude - tap.latitude, (nearest.longitude - tap.longitude) * 0.73) - return nearest.takeIf { distance < 0.00135 } +private fun meetingAnchor( + index: Int, + destinations: List, + paths: Map, + building: CampusBuildingGeometry, +): LatLng { + if (index > 0) { + val incoming = paths[segmentKey(destinations[index - 1], destinations[index])] + incoming?.coordinates?.lastOrNull()?.let { (longitude, latitude) -> + return LatLng(latitude, longitude) + } + } + if (index < destinations.lastIndex) { + val outgoing = paths[segmentKey(destinations[index], destinations[index + 1])] + outgoing?.coordinates?.firstOrNull()?.let { (longitude, latitude) -> + return LatLng(latitude, longitude) + } + } + val entrance = building.entrances.firstOrNull() + return if (entrance != null) LatLng(entrance.latitude, entrance.longitude) + else LatLng(building.latitude, building.longitude) +} + +private fun segmentKey(from: Meeting, to: Meeting): String = "${from.id}--${to.id}" + +private fun ensureCanonicalBuildingLayer(style: Style, features: List) { + if (features.isEmpty()) return + val collection = FeatureCollection.fromFeatures(features) + val source = style.getSource(BUILDING_HIT_SOURCE) as? GeoJsonSource + if (source == null) { + style.addSource(GeoJsonSource(BUILDING_HIT_SOURCE, collection)) + } else { + source.setGeoJson(collection) + } + if (style.getLayer(BUILDING_HIT_FILL) == null) { + style.addLayer( + FillLayer(BUILDING_HIT_FILL, BUILDING_HIT_SOURCE).withProperties( + fillColor("#000000"), + fillOpacity(0.001f), + ), + ) + } } -private fun highlightFeature(style: Style, feature: Feature, darkTheme: Boolean) { +private fun syncSelectedBuildingHighlight( + style: Style, + code: String?, + buildings: Map, + darkTheme: Boolean, +) { clearBuildingHighlight(style) + val feature = code?.let(buildings::get)?.footprint ?: return style.addSource(GeoJsonSource(SELECTED_BUILDING_SOURCE, feature)) val accent = if (darkTheme) "#60A5FA" else "#146BB8" style.addLayer( FillLayer(SELECTED_BUILDING_FILL, SELECTED_BUILDING_SOURCE).withProperties( fillColor(accent), - fillOpacity(0.28f), + fillOpacity(0.30f), fillOutlineColor(accent), ), ) @@ -801,11 +875,16 @@ private fun clearBuildingHighlight(style: Style) { if (style.getSource(SELECTED_BUILDING_SOURCE) != null) style.removeSource(SELECTED_BUILDING_SOURCE) } -private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations: List, animated: Boolean) { - val points = destinations.mapNotNull { meeting -> meeting.buildingCode?.let(BuildingPoints::get) } +private fun fitDayRoute( + map: org.maplibre.android.maps.MapLibreMap, + destinations: List, + buildings: Map, + animated: Boolean, +) { + val points = destinations.mapNotNull { meeting -> meeting.buildingCode?.let(buildings::get) } if (points.isEmpty()) { val update = CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM) - if (animated) map.animateCamera(update, 360) else map.moveCamera(update) + if (animated) map.animateCamera(update, 340) else map.moveCamera(update) return } val centerLat = points.map { it.latitude }.average() @@ -821,7 +900,7 @@ private fun fitDayRoute(map: org.maplibre.android.maps.MapLibreMap, destinations else -> 15.2 } val update = CameraUpdateFactory.newLatLngZoom(LatLng(centerLat, centerLon), zoom) - if (animated) map.animateCamera(update, 360) else map.moveCamera(update) + if (animated) map.animateCamera(update, 340) else map.moveCamera(update) } private fun createTimeMarkerBitmap( @@ -831,21 +910,18 @@ private fun createTimeMarkerBitmap( stackFromBottom: Int, selected: Boolean, ): Bitmap { - val horizontalPadding = 10f * density - val verticalPadding = 5f * density + val horizontalPadding = 9f * density + val verticalPadding = 4.5f * density val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.WHITE textSize = 10.5f * density - typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD) } val textWidth = textPaint.measureText(label) - val bodyWidth = (textWidth + horizontalPadding * 2).coerceAtLeast(58f * density) + val bodyWidth = (textWidth + horizontalPadding * 2).coerceAtLeast(56f * density) val bodyHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2 val shadowPad = 3f * density - // MapLibre centers custom marker bitmaps on their geographic coordinate. Extra transparent - // space below the pill therefore produces a stable screen-space upward offset without - // inventing fake latitude offsets. One full slot remains below the lowest time for E/A/♿. - val bottomPadding = (64f + stackFromBottom * 58f) * density + val bottomPadding = (38f + stackFromBottom * 33f) * density val width = (bodyWidth + shadowPad * 2).toInt().coerceAtLeast(1) val height = (bodyHeight + shadowPad * 2 + bottomPadding).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) @@ -873,18 +949,18 @@ private fun createTimeMarkerBitmap( } private fun createEntranceMarkerBitmap( - entrance: EntrancePoint, + entrance: CampusEntrance, darkTheme: Boolean, density: Float, ): Bitmap { - val canvasSize = (38f * density).toInt().coerceAtLeast(1) + val canvasSize = (34f * density).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val center = canvasSize / 2f - val radius = 14f * density + val radius = 13f * density val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (darkTheme) android.graphics.Color.rgb(11, 17, 26) else android.graphics.Color.WHITE - setShadowLayer(3f * density, 0f, 1.5f * density, 0x66000000) + setShadowLayer(2.5f * density, 0f, 1.5f * density, 0x66000000) } val accent = if (darkTheme) android.graphics.Color.rgb(96, 165, 250) else android.graphics.Color.rgb(20, 107, 184) val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -903,7 +979,7 @@ private fun createEntranceMarkerBitmap( } val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent - textSize = (if (markerText == "♿") 12f else 12.5f) * density + textSize = (if (markerText == "♿") 11.5f else 12f) * density typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) textAlign = Paint.Align.CENTER } From 4a5df931e27756a5c2e62f3b3e9e7386870d2656 Mon Sep 17 00:00:00 2001 From: Andrew Muratov Date: Sun, 13 Sep 2026 11:26:26 -0400 Subject: [PATCH 29/29] Cache canonical campus map data --- .../android/feature/map/CampusMapApi.kt | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt b/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt index ba6cde1..e5290c7 100644 --- a/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt +++ b/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt @@ -8,6 +8,7 @@ import org.json.JSONObject import org.maplibre.geojson.Feature import java.net.HttpURLConnection import java.net.URL +import java.util.concurrent.ConcurrentHashMap internal data class CampusEntrance( val id: String, @@ -32,10 +33,7 @@ internal data class CampusBuildingGeometry( internal data class CampusMapSnapshot( val dataVersion: String, val buildings: Map, -) { - val footprintFeatures: List - get() = buildings.values.mapNotNull { it.footprint } -} +) internal data class CampusPathGeometry( val status: String, @@ -51,33 +49,47 @@ internal data class CampusRoutePreferences( } internal object CampusMapApi { - private const val MAP_URL = "https://gapwise.ca/api/utm-map" - private const val PATH_URL = "https://gapwise.ca/api/utm-path" + private const val MAP_URL = "https://gapwise.ca/api/utm-buildings?geometry=1" + private const val PATH_URL = "https://gapwise.ca/api/utm-route" private const val CONNECT_TIMEOUT_MS = 5_000 private const val READ_TIMEOUT_MS = 8_000 - suspend fun fetchMap(): CampusMapSnapshot = withContext(Dispatchers.IO) { - parseMap(request(MAP_URL, "GET", null)) + @Volatile + private var mapCache: CampusMapSnapshot? = null + private val pathCache = ConcurrentHashMap() + + suspend fun fetchMap(): CampusMapSnapshot { + mapCache?.let { return it } + return withContext(Dispatchers.IO) { + mapCache ?: parseMap(request(MAP_URL, "GET", null)).also { mapCache = it } + } } suspend fun fetchPath( from: String, to: String, preferences: CampusRoutePreferences, - ): CampusPathGeometry = withContext(Dispatchers.IO) { - if (from == to) return@withContext CampusPathGeometry("same-building", emptyList()) - val body = JSONObject() - .put("from", from) - .put("to", to) - .put( - "preferences", - JSONObject() - .put("mode", preferences.mode.apiValue()) - .put("walkingSpeedMps", preferences.walkingSpeedMps.toDouble()) - .put("transitionBufferMinutes", preferences.transitionBufferMinutes), - ) - .toString() - parsePath(request(PATH_URL, "POST", body)) + ): CampusPathGeometry { + if (from == to) return CampusPathGeometry("same-building", emptyList()) + val cacheKey = "$from>$to@${preferences.cacheKey()}" + pathCache[cacheKey]?.let { return it } + return withContext(Dispatchers.IO) { + pathCache[cacheKey] ?: run { + val body = JSONObject() + .put("from", from) + .put("to", to) + .put("includeGeometry", true) + .put( + "preferences", + JSONObject() + .put("mode", preferences.mode.apiValue()) + .put("walkingSpeedMps", preferences.walkingSpeedMps.toDouble()) + .put("transitionBufferMinutes", preferences.transitionBufferMinutes), + ) + .toString() + parsePath(request(PATH_URL, "POST", body)).also { pathCache[cacheKey] = it } + } + } } private fun request(url: String, method: String, body: String?): String { @@ -86,6 +98,7 @@ internal object CampusMapApi { connection.requestMethod = method connection.connectTimeout = CONNECT_TIMEOUT_MS connection.readTimeout = READ_TIMEOUT_MS + connection.useCaches = true connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("User-Agent", "Gapwise-Android/0.1") if (body != null) { @@ -152,6 +165,7 @@ internal object CampusMapApi { private fun parsePath(raw: String): CampusPathGeometry { val root = JSONObject(raw) + val route = root.optJSONObject("route") val coordinatesJson = root.optJSONArray("displayCoordinates") ?: JSONArray() val coordinates = buildList { for (index in 0 until coordinatesJson.length()) { @@ -160,7 +174,7 @@ internal object CampusMapApi { } } return CampusPathGeometry( - status = root.optString("status", if (coordinates.size >= 2) "routed" else "unavailable"), + status = route?.optString("status") ?: if (coordinates.size >= 2) "routed" else "unavailable", coordinates = coordinates, ) }