diff --git a/.github/workflows/parity-build.yml b/.github/workflows/parity-build.yml new file mode 100644 index 0000000..ffe8976 --- /dev/null +++ b/.github/workflows/parity-build.yml @@ -0,0 +1,46 @@ +name: Android parity build + +on: + push: + branches: [web-mobile-ui-parity] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +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 + with: + gradle-version: current + - name: Compile Android app + shell: bash + run: | + set -o pipefail + gradle :app:compileDebugKotlin --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 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 + echo '```' + } > diagnostics.md + gh pr comment "$PR_NUMBER" --body-file diagnostics.md 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), 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, + ) +} 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..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,27 +1,40 @@ 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.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.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.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 +45,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 +55,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.6f).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 +354,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 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..e5290c7 --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/map/CampusMapApi.kt @@ -0,0 +1,187 @@ +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 +import java.util.concurrent.ConcurrentHashMap + +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, +) + +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-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 + + @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 { + 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 { + val connection = URL(url).openConnection() as HttpURLConnection + try { + 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) { + 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 route = root.optJSONObject("route") + 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 = route?.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" +} 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..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 @@ -1,313 +1,1006 @@ 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.RectF +import android.view.MotionEvent +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.heightIn 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.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 import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 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 import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource +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 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 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 +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 org.maplibre.android.maps.Style +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.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.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" -private data class BuildingPoint( - val code: String, - val name: String, - val latitude: Double, - val longitude: Double, +private val BaseDays = listOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, ) -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 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 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 destinations = remember(meetings) { + 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) + } + 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) } + + 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), + ) { + 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.6f).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 }, + modifier = Modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.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), + 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, + routePreferences = routePreferences, + ) + } + } + + RoutePreferencesSheet(open = optionsOpen, onDismiss = { optionsOpen = false }) +} + +@Composable +private fun CampusMapCard( + meetings: List, + darkTheme: Boolean, + routePreferences: CampusRoutePreferences, +) { + 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) } - .distinctBy { 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) } - val results = remember(query) { + var selectedMeetingId by remember { mutableStateOf(null) } + var fitRequest by remember { mutableIntStateOf(0) } + var campusRequest by remember { mutableIntStateOf(0) } + + 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 -> - point.code.lowercase().contains(normalized) || - point.name.lowercase().contains(normalized) - } - .take(8) + 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 mapKey = destinations.joinToString("|") { it.buildingCode.orEmpty() } + val selected = selectedCode?.let(buildings::get) + val selectedMeeting = selectedMeetingId?.let { id -> destinations.firstOrNull { it.id == id } } - Box( - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - .clip(RoundedCornerShape(16.dp)), + Surface( + modifier = Modifier.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) { + Box(modifier = Modifier.fillMaxSize()) { UtmMap( destinations = destinations, + buildings = buildings, + paths = paths, darkTheme = darkTheme, - focusedBuilding = selected, - onBuildingSelected = { selectedCode = it }, + focusedBuildingCode = selected?.code, + 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 - .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(start = 10.dp, top = 10.dp, end = 68.dp), ) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - placeholder = { Text("Search MN, Deerfield…") }, - leadingIcon = { - Icon( - painter = painterResource(R.drawable.ic_lucide_search), - contentDescription = null, - ) - }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.weight(1f), - ) - OutlinedButton(onClick = { selectedCode = null }) { - Text("Campus") - } - } - if (query.isNotBlank()) { Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(9.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), - tonalElevation = 4.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { - 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), + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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), + 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 = 5.dp), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.99f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(5.dp)) { + if (results.isEmpty()) { + Text( + "No mapped UTM building matches that search.", + modifier = Modifier.padding(9.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } else { + results.forEach { building -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + selectedMeetingId = null + selectedCode = building.code + query = "" + } + .padding(horizontal = 10.dp, vertical = 9.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, - ) + 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.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( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp) - .fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), - tonalElevation = 5.dp, + Column( + modifier = Modifier.align(Alignment.TopEnd).padding(top = 84.dp, end = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - 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, - ) - } + MapControl(Icons.Outlined.Maximize) { + selectedCode = null + selectedMeetingId = null + fitRequest += 1 } + MapControl(Icons.Outlined.MyLocation) { + selectedCode = null + selectedMeetingId = null + campusRequest += 1 + } + } + + if (selectedMeeting != null) { + MeetingMapCard( + meeting = selectedMeeting, + building = selectedMeeting.buildingCode?.let(buildings::get), + onClose = { selectedMeetingId = null }, + 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), + ) } } } } +@Composable +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), + ) { + 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: 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), + 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.category}") + Text(building.name, modifier = Modifier.padding(top = 5.dp), fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + CloseMapCardButton("Close building details", onClose) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + 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( + 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, + lineHeight = 16.sp, + ) + } + } +} + +@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), + shape = RoundedCornerShape(9.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), + 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, + buildings: Map, + paths: Map, darkTheme: Boolean, - focusedBuilding: BuildingPoint?, + focusedBuildingCode: String?, + focusedMeeting: Meeting?, + fitRequest: Int, + campusRequest: Int, + onMeetingSelected: (String) -> Unit, 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 mapView = remember(styleUrl, destinations) { + 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) MapView(context).apply { 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) - 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) - false - } - map.setStyle(styleUrl) { - BuildingPoints.values.forEach { point -> - map.addMarker( - MarkerOptions() - .position(LatLng(point.latitude, point.longitude)) - .title("${point.code} · ${point.name}") - .snippet(point.code), - ) + map.cameraPosition = CameraPosition.Builder().target(LatLng(UTM_LAT, UTM_LON)).zoom(CAMPUS_ZOOM).build() + map.setStyle(styleUrl) + } + } + } + + LaunchedEffect(mapView) { lastAutoFitKey = null } + + DisposableEffect(mapView) { + var mapClickListener: org.maplibre.android.maps.MapLibreMap.OnMapClickListener? = null + mapView.getMapAsync { map -> + map.setOnMarkerClickListener { marker -> + val snippet = marker.snippet.orEmpty() + 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 + 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) + mapClickListener = listener + } + onDispose { + mapView.getMapAsync { map -> mapClickListener?.let(map::removeOnMapClickListener) } + mapView.onPause() + mapView.onStop() + mapView.onDestroy() } } - LaunchedEffect(focusedBuilding?.code, mapView) { + 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 -> - 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 + 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(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 -> map.animateCamera( - CameraUpdateFactory.newLatLngZoom(target.first, target.second), - 450, + CameraUpdateFactory.newLatLngZoom(LatLng(building.latitude, building.longitude), BUILDING_ZOOM), + 340, ) } } - DisposableEffect(mapView) { - onDispose { - mapView.onPause() - mapView.onStop() - mapView.onDestroy() + LaunchedEffect(fitRequest, buildings, mapView) { + if (fitRequest > 0) mapView.getMapAsync { map -> + fitDayRoute(map, destinations, buildings, animated = true) + } + } + + LaunchedEffect(campusRequest, mapView) { + if (campusRequest > 0) mapView.getMapAsync { map -> + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(UTM_LAT, UTM_LON), CAMPUS_ZOOM), 340) } } AndroidView(factory = { mapView }, modifier = modifier) } + +private fun renderDayOverlays( + map: org.maplibre.android.maps.MapLibreMap, + 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), + ) + } + + 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}"), + ) + } + + 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}"), + ) + } + } +} + +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 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.30f), + 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, + 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, 340) 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.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, 340) else map.moveCamera(update) +} + +private fun createTimeMarkerBitmap( + label: String, + darkTheme: Boolean, + density: Float, + stackFromBottom: Int, + selected: Boolean, +): Bitmap { + 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.MONOSPACE, android.graphics.Typeface.BOLD) + } + val textWidth = textPaint.measureText(label) + val bodyWidth = (textWidth + horizontalPadding * 2).coerceAtLeast(56f * density) + val bodyHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent + verticalPadding * 2 + val shadowPad = 3f * 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) + 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(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 = (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) + } + canvas.drawRoundRect(rect, radius, radius, fill) + canvas.drawRoundRect(rect, radius, radius, stroke) + 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: CampusEntrance, + darkTheme: Boolean, + density: Float, +): Bitmap { + 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 = 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(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 { + 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 == "♿") 11.5f else 12f) * density + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + textAlign = Paint.Align.CENTER + } + val baseline = center - (textPaint.fontMetrics.ascent + textPaint.fontMetrics.descent) / 2f + canvas.drawText(markerText, center, baseline, textPaint) + return bitmap +} + +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 +} 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) } +} 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..7526b3d --- /dev/null +++ b/app/src/main/java/ca/gapwise/android/feature/settings/CompactMoreSheet.kt @@ -0,0 +1,228 @@ +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 +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 +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, + signedIn: Boolean, + canRemoveTimetable: Boolean, + onOpenAcademicPreferences: () -> Unit, + onToggleTheme: () -> Unit, + onOpenArrivalPreferences: () -> 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), + ) { + 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 { + Box { + Surface( + onClick = { if (signedIn) accountMenuOpen = true else onOpenSettings() }, + 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, + ) + 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() + }, + ) + 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() + }, + ) + } + } + } + } + + 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, + ) + } +} 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, + ) + } +} 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..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,5 +1,7 @@ package ca.gapwise.android.feature.timetable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -7,23 +9,33 @@ 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.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.material.icons.outlined.AutoAwesome 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.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.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 @@ -38,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) @@ -67,70 +89,96 @@ 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(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 +198,239 @@ 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) + gapAfterMeeting[meeting.id]?.let { gap -> + GapCard(gap = gap, onClick = onOpenGapPlan) + } } + } + } + } + } + } +} + +@Composable +private fun MeetingCard(meeting: Meeting) { + val accent = activityAccent(meeting) + val location = locationPresentation(meeting) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 0.dp, + ) { + 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.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 = if (meeting.isAssessmentWindow) Icons.Outlined.AccessTime else Icons.Outlined.Place, + contentDescription = null, + tint = accent, + ) + Column { + Text( + text = location.first, + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + location.second?.let { floor -> Text( - text = listOf(meeting.activityType.name, meeting.sectionCode) - .filter(String::isNotBlank) - .joinToString(" · "), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = meeting.locationLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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, ) } } } + + 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 + 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 +438,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 +470,15 @@ 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) + private fun currentTerm(): Term = when (LocalDate.now().monthValue) { in 1..4 -> Term.WINTER in 5..8 -> Term.SUMMER 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 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..a301b87 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,12 +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.SettingsScreen +import ca.gapwise.android.feature.settings.AccountSettingsDialog +import ca.gapwise.android.feature.settings.CompactMoreSheetContent +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 @@ -87,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) { @@ -98,6 +105,8 @@ fun GapwiseApp( var syncBusy by remember { mutableStateOf(false) } var syncStatus by remember { mutableStateOf(null) } var moreOpen by remember { mutableStateOf(false) } + var accountSettingsOpen by remember { mutableStateOf(false) } + var routePreferencesOpen by remember { mutableStateOf(false) } fun saveTimetable(value: List) { meetings = value @@ -115,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) } @@ -154,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() } @@ -189,10 +202,30 @@ 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) + 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) @@ -207,10 +240,12 @@ 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), ) @@ -218,137 +253,155 @@ fun GapwiseApp( color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(top = 8.dp, bottom = 14.dp), ) - SettingsScreen( - meetings = meetings, - importStatus = importStatus, + CompactMoreSheetContent( themeMode = themeMode, - account = account, - syncEnabled = syncEnabled, - syncBusy = syncBusy, - syncStatus = syncStatus, - onThemeModeChange = onThemeModeChange, - onImport = { + accountLabel = account?.email?.substringBefore('@')?.takeIf { it.isNotBlank() } + ?: if (account == null) "Sign in" else "Account", + signedIn = account != null, + canRemoveTimetable = meetings.isNotEmpty(), + onOpenAcademicPreferences = { + moreOpen = false + 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 + routePreferencesOpen = true + }, + onOpenSettings = { + moreOpen = false + accountSettingsOpen = true + }, + onSignOut = { + moreOpen = false + signOut() + }, + onDeleteAccount = { + moreOpen = false + accountSettingsOpen = true + }, + onUpdateTimetable = { moreOpen = false startImport() }, - onClearTimetable = { + onRemoveTimetable = { saveTimetable(emptyList()) importStatus = "Timetable removed from this device." pushIfEnabled(emptyList()) + moreOpen = 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 = { + ) + } + } + + 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 @@ -377,6 +430,7 @@ private fun GapwiseTopBar(pageLabel: String) { text = "Gapwise", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.5).sp, ) } Text( @@ -432,29 +486,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, + ) + } } }