From 986037fe4765db5e33b49392878b2d843ea74a29 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 19:09:14 -0400 Subject: [PATCH 1/7] Add Compose dependencies and some components --- app/build.gradle | 15 +- .../coursegrab/ui/components/CourseRow.kt | 147 ++++++++++++++++++ .../coursegrab/ui/components/EmptyState.kt | 83 ++++++++++ .../ui/components/StatusIndicator.kt | 35 +++++ .../coursegrab/ui/components/TrackButton.kt | 87 +++++++++++ .../coursegrab/ui/theme/Color.kt | 20 +++ .../coursegrab/ui/theme/Theme.kt | 30 ++++ .../cornellappdev/coursegrab/ui/theme/Type.kt | 35 +++++ app/src/main/res/values/strings.xml | 2 + build.gradle | 4 + 10 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/StatusIndicator.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Type.kt diff --git a/app/build.gradle b/app/build.gradle index 3996160..94f4ec5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget apply plugin: 'com.android.application' apply plugin: 'kotlin-parcelize' apply plugin: 'org.jetbrains.kotlin.plugin.serialization' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'com.google.devtools.ksp' apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'com.google.firebase.crashlytics' @@ -38,6 +39,7 @@ android { buildFeatures { viewBinding = true buildConfig = true + compose = true } namespace = 'com.cornellappdev.coursegrab' } @@ -70,7 +72,18 @@ dependencies { implementation 'androidx.core:core-ktx:1.19.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.11.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0' - implementation 'androidx.activity:activity-ktx:1.13.0' + implementation "androidx.activity:activity-ktx:${rootProject.ext.activity_version}" + + // Compose + implementation platform("androidx.compose:compose-bom:${rootProject.ext.compose_bom_version}") + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.11.0' + implementation "androidx.activity:activity-compose:${rootProject.ext.activity_version}" + implementation "androidx.hilt:hilt-navigation-compose:${rootProject.ext.hilt_navigation_compose_version}" + debugImplementation 'androidx.compose.ui:ui-tooling' implementation "com.google.dagger:hilt-android:${rootProject.ext.hilt_version}" ksp "com.google.dagger:hilt-compiler:${rootProject.ext.hilt_version}" implementation 'androidx.constraintlayout:constraintlayout:2.2.2' diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt new file mode 100644 index 0000000..9e2ca57 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt @@ -0,0 +1,147 @@ +package com.cornellappdev.coursegrab.ui.components + +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.Row +import androidx.compose.foundation.layout.RowScope +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.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.coursegrab.R +import com.cornellappdev.coursegrab.models.Course +import com.cornellappdev.coursegrab.ui.theme.CardBorder +import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme +import com.cornellappdev.coursegrab.ui.theme.PrimaryText +import java.util.Locale + +@Composable +fun CourseRow( + course: Course, + onClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {} +) { + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, CardBorder), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = stringResource( + R.string.course_title_format, + course.subjectCode, + course.courseNum, + course.title + ), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + StatusIndicator(isOpen = course.isOpen, modifier = Modifier.padding(start = 12.dp)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + // US-only app: invariant casing, and it keeps the locale out of composition. + text = course.section.uppercase(Locale.ROOT), + style = MaterialTheme.typography.bodyLarge, + // No textColor in the XML, so these inherit textColorPrimary, not the + // pure black the title sets explicitly. + color = PrimaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = course.catalogNum.toString(), + style = MaterialTheme.typography.bodyLarge, + color = PrimaryText, + modifier = Modifier.padding(start = 12.dp) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .height(BUTTON_ROW_HEIGHT), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } + } +} + +private val BUTTON_ROW_HEIGHT = 44.dp + +private val SampleCourse = Course( + catalogNum = 10032, + courseNum = 1998, + section = "LEC 006 / M 7:30PM", + isTracking = false, + status = "OPEN", + subjectCode = "CS", + title = "Intro to Android Development", + numTracking = 12 +) + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun CourseRowOpenPreview() { + CourseGrabTheme { + CourseRow(course = SampleCourse, onClick = {}) { + TrackButton( + isTracking = false, + onToggle = {}, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun CourseRowClosedPreview() { + CourseGrabTheme { + CourseRow( + course = SampleCourse.copy(status = "CLOSED", isTracking = true), + onClick = {} + ) { + TrackButton( + isTracking = true, + onToggle = {}, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + } + } +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt new file mode 100644 index 0000000..9d84b06 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt @@ -0,0 +1,83 @@ +package com.cornellappdev.coursegrab.ui.components + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.coursegrab.R +import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme + +@Composable +fun EmptyState( + @DrawableRes icon: Int, + @StringRes title: Int, + @StringRes subtitle: Int, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // The status drawables are 16dp intrinsic — the empty-state ImageViews scale them + // to 96dp. Without an explicit size these render as the tiny row-status dot. + Image( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(ICON_SIZE) + ) + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +private val ICON_SIZE = 96.dp + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun EmptyStateNoResultsPreview() { + CourseGrabTheme { + EmptyState( + icon = R.drawable.ic_status_closed, + title = R.string.no_courses_alert, + subtitle = R.string.no_results_alert_subtext_try_another + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun EmptyStateFailedPreview() { + CourseGrabTheme { + EmptyState( + icon = R.drawable.ic_status_warning, + title = R.string.search_failed, + subtitle = R.string.search_failed_subtext + ) + } +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/StatusIndicator.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/StatusIndicator.kt new file mode 100644 index 0000000..6aae9ff --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/StatusIndicator.kt @@ -0,0 +1,35 @@ +package com.cornellappdev.coursegrab.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.cornellappdev.coursegrab.R +import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme + +@Composable +fun StatusIndicator(isOpen: Boolean, modifier: Modifier = Modifier) { + Image( + painter = painterResource( + if (isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed + ), + contentDescription = stringResource( + if (isOpen) R.string.status_open else R.string.status_closed + ), + modifier = modifier + ) +} + +@Preview +@Composable +private fun StatusIndicatorPreview() { + CourseGrabTheme { StatusIndicator(isOpen = true) } +} + +@Preview +@Composable +private fun StatusIndicatorClosedPreview() { + CourseGrabTheme { StatusIndicator(isOpen = false) } +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt new file mode 100644 index 0000000..27a0523 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt @@ -0,0 +1,87 @@ +package com.cornellappdev.coursegrab.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.coursegrab.R +import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme +import com.cornellappdev.coursegrab.ui.theme.RemovalRed +import java.util.Locale + +/** + * Matches the existing XML buttons rather than Material3 defaults: white fill and a 1dp + * outline in the accent color — see `track_button_background.xml` and + * `remove_button_background.xml`. A filled M3 Button looks nothing like the shipped app. + * + * Size is the caller's job, because the two usages differ: the course rows give it the + * full row width at 44dp, while the section rows use 26dp and wrap. Pass both through + * [modifier]. + * + * One control replaces the two mutually-exclusive Buttons the rows toggle between with + * `View.VISIBLE` / `View.GONE`. + */ +@Composable +fun TrackButton( + isTracking: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier +) { + val accent = if (isTracking) RemovalRed else Color.Black + OutlinedButton( + onClick = onToggle, + modifier = modifier, + // The two drawables differ here: 1dp on track, 3dp on remove. + shape = RoundedCornerShape(if (isTracking) 3.dp else 1.dp), + border = BorderStroke(1.dp, accent), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = accent + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text( + text = stringResource( + if (isTracking) R.string.remove_button else R.string.track_button + ).uppercase(Locale.ROOT), + style = MaterialTheme.typography.labelMedium + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TrackButtonSectionPreview() { + CourseGrabTheme { + TrackButton( + isTracking = false, + onToggle = {}, + modifier = Modifier.height(26.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TrackButtonRowPreview() { + CourseGrabTheme { + TrackButton( + isTracking = true, + onToggle = {}, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + ) + } +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt new file mode 100644 index 0000000..cf81056 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package com.cornellappdev.coursegrab.ui.theme + +import androidx.compose.ui.graphics.Color + +val Teal = Color(0xFF008577) +val TealDark = Color(0xFF00574B) +val Pink = Color(0xFFD81B60) +val RemovalRed = Color(0xFFCA4238) +val LightText = Color(0xFF9EA7B3) +val StatusGreen = Color(0xFF47C753) +val DarkEnough = Color(0xFF1C1F23) + +val CardBorder = Color(0xFFD1D5DA) + +val PrimaryText = Color(0xDE000000) + +object StatusColors { + val open = StatusGreen + val closed = RemovalRed +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt new file mode 100644 index 0000000..c6dbf40 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt @@ -0,0 +1,30 @@ +package com.cornellappdev.coursegrab.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val CourseGrabColorScheme = lightColorScheme( + primary = Teal, + onPrimary = Color.White, + primaryContainer = TealDark, + secondary = Pink, + background = Color.White, + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + onSurfaceVariant = LightText, + error = RemovalRed, + onError = Color.White, + outline = LightText +) + +@Composable +fun CourseGrabTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = CourseGrabColorScheme, + typography = CourseGrabTypography, + content = content + ) +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Type.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Type.kt new file mode 100644 index 0000000..10ae841 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Type.kt @@ -0,0 +1,35 @@ +package com.cornellappdev.coursegrab.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val CourseGrabTypography = Typography( + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 24.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 20.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 18.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp + ) +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c93c206..288940f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,8 @@ %d Tracking %d Tracking + Open + Closed %d Result %d Results diff --git a/build.gradle b/build.gradle index 0a13479..830bbcc 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,9 @@ buildscript { ext.hilt_version = '2.60.1' ext.serialization_json_version = '1.9.0' ext.retrofit_version = '3.0.0' + ext.compose_bom_version = '2026.08.00' + ext.activity_version = '1.13.0' + ext.hilt_navigation_compose_version = '1.4.0' repositories { google() mavenCentral() @@ -18,6 +21,7 @@ buildscript { classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:$ksp_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } From 7a06e64ee6c196a26d5b00d128604c24ba1967af Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 18:20:22 -0400 Subject: [PATCH 2/7] Share preview fixtures across components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CourseRow's SampleCourse hand-wrote the backend's status vocabulary, and every screen preview added on top of this branch repeats the same shape. sampleCourse() gives them one source: previews say isOpen and the fixture is the only place that turns that back into the wire value. There is deliberately no STATUS_CLOSED constant to go with Course's private STATUS_OPEN. Nothing compares against "CLOSED" — isOpen treats anything that isn't "OPEN" as closed — so a constant would imply a branch that does not exist, and would go stale the moment the backend adds another status. Co-Authored-By: Claude Opus 5 --- .../ui/components/CoursePreviewData.kt | 49 +++++++++++++++++++ .../coursegrab/ui/components/CourseRow.kt | 15 +----- 2 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt new file mode 100644 index 0000000..5eb6264 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt @@ -0,0 +1,49 @@ +package com.cornellappdev.coursegrab.ui.components + +import com.cornellappdev.coursegrab.models.Course +import com.cornellappdev.coursegrab.models.SearchResult + +/** + * Sample data for `@Preview`s. + * + * Status is a backend string that only [Course.isOpen] knows how to read — and it reads it + * as "anything that isn't OPEN is closed", so there is no `CLOSED` constant to share. + * Previews say [isOpen] and this is the single place that turns it back into the wire value. + */ +internal fun sampleCourse( + catalogNum: Int = 10032, + subjectCode: String = "CS", + courseNum: Int = 1998, + title: String = "Intro to Android Development", + section: String = "LEC 001 / M 7:30PM", + isOpen: Boolean = true, + isTracking: Boolean = false, + numTracking: Int = 12 +): Course = Course( + catalogNum = catalogNum, + courseNum = courseNum, + section = section, + instructors = listOf(SAMPLE_INSTRUCTOR), + isTracking = isTracking, + status = if (isOpen) STATUS_OPEN else STATUS_CLOSED, + subjectCode = subjectCode, + title = title, + numTracking = numTracking +) + +internal fun sampleSearchResult( + subjectCode: String = "CS", + courseNum: Int = 1998, + title: String = "Intro to Android Development", + sections: List = listOf(sampleCourse()) +): SearchResult = SearchResult( + subjectCode = subjectCode, + courseNum = courseNum, + title = title, + sections = sections +) + +internal const val SAMPLE_INSTRUCTOR = "Adrian Sampson (als485)" + +private const val STATUS_OPEN = "OPEN" +private const val STATUS_CLOSED = "CLOSED" diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt index 9e2ca57..a7e0c94 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt @@ -100,22 +100,11 @@ fun CourseRow( private val BUTTON_ROW_HEIGHT = 44.dp -private val SampleCourse = Course( - catalogNum = 10032, - courseNum = 1998, - section = "LEC 006 / M 7:30PM", - isTracking = false, - status = "OPEN", - subjectCode = "CS", - title = "Intro to Android Development", - numTracking = 12 -) - @Preview(showBackground = true, widthDp = 360) @Composable private fun CourseRowOpenPreview() { CourseGrabTheme { - CourseRow(course = SampleCourse, onClick = {}) { + CourseRow(course = sampleCourse(), onClick = {}) { TrackButton( isTracking = false, onToggle = {}, @@ -132,7 +121,7 @@ private fun CourseRowOpenPreview() { private fun CourseRowClosedPreview() { CourseGrabTheme { CourseRow( - course = SampleCourse.copy(status = "CLOSED", isTracking = true), + course = sampleCourse(isOpen = false, isTracking = true), onClick = {} ) { TrackButton( From ed1540c7947d2b53b3cb222920b31bcaaf561a92 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 18:29:01 -0400 Subject: [PATCH 3/7] Split TrackButton into TrackButton and RemoveButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One composable with an isTracking flag read as a toggle that could go either way, but most call sites know statically which button they want — the course rows on the tracked-courses screen only ever remove, and passing isTracking = true there to mean "this is the remove button" was obscure. Both delegate to a private OutlinedActionButton holding the shared shape; the accent color, corner radius and label are the only things that differed, and they now sit next to the drawable each one mirrors. Renames the file to TrackingButtons.kt since it holds both. Co-Authored-By: Claude Opus 5 --- .../ui/components/CoursePreviewData.kt | 7 -- .../coursegrab/ui/components/CourseRow.kt | 11 +- .../coursegrab/ui/components/EmptyState.kt | 2 - .../coursegrab/ui/components/TrackButton.kt | 87 -------------- .../ui/components/TrackingButtons.kt | 112 ++++++++++++++++++ 5 files changed, 115 insertions(+), 104 deletions(-) delete mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt index 5eb6264..e1f863e 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CoursePreviewData.kt @@ -3,13 +3,6 @@ package com.cornellappdev.coursegrab.ui.components import com.cornellappdev.coursegrab.models.Course import com.cornellappdev.coursegrab.models.SearchResult -/** - * Sample data for `@Preview`s. - * - * Status is a backend string that only [Course.isOpen] knows how to read — and it reads it - * as "anything that isn't OPEN is closed", so there is no `CLOSED` constant to share. - * Previews say [isOpen] and this is the single place that turns it back into the wire value. - */ internal fun sampleCourse( catalogNum: Int = 10032, subjectCode: String = "CS", diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt index a7e0c94..6c41058 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt @@ -68,11 +68,8 @@ fun CourseRow( Row(verticalAlignment = Alignment.CenterVertically) { Text( - // US-only app: invariant casing, and it keeps the locale out of composition. text = course.section.uppercase(Locale.ROOT), style = MaterialTheme.typography.bodyLarge, - // No textColor in the XML, so these inherit textColorPrimary, not the - // pure black the title sets explicitly. color = PrimaryText, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -106,8 +103,7 @@ private fun CourseRowOpenPreview() { CourseGrabTheme { CourseRow(course = sampleCourse(), onClick = {}) { TrackButton( - isTracking = false, - onToggle = {}, + onClick = {}, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -124,9 +120,8 @@ private fun CourseRowClosedPreview() { course = sampleCourse(isOpen = false, isTracking = true), onClick = {} ) { - TrackButton( - isTracking = true, - onToggle = {}, + RemoveButton( + onClick = {}, modifier = Modifier .weight(1f) .fillMaxHeight() diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt index 9d84b06..4bee818 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/EmptyState.kt @@ -35,8 +35,6 @@ fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // The status drawables are 16dp intrinsic — the empty-state ImageViews scale them - // to 96dp. Without an explicit size these render as the tiny row-status dot. Image( painter = painterResource(icon), contentDescription = null, diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt deleted file mode 100644 index 27a0523..0000000 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackButton.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.cornellappdev.coursegrab.ui.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.cornellappdev.coursegrab.R -import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme -import com.cornellappdev.coursegrab.ui.theme.RemovalRed -import java.util.Locale - -/** - * Matches the existing XML buttons rather than Material3 defaults: white fill and a 1dp - * outline in the accent color — see `track_button_background.xml` and - * `remove_button_background.xml`. A filled M3 Button looks nothing like the shipped app. - * - * Size is the caller's job, because the two usages differ: the course rows give it the - * full row width at 44dp, while the section rows use 26dp and wrap. Pass both through - * [modifier]. - * - * One control replaces the two mutually-exclusive Buttons the rows toggle between with - * `View.VISIBLE` / `View.GONE`. - */ -@Composable -fun TrackButton( - isTracking: Boolean, - onToggle: () -> Unit, - modifier: Modifier = Modifier -) { - val accent = if (isTracking) RemovalRed else Color.Black - OutlinedButton( - onClick = onToggle, - modifier = modifier, - // The two drawables differ here: 1dp on track, 3dp on remove. - shape = RoundedCornerShape(if (isTracking) 3.dp else 1.dp), - border = BorderStroke(1.dp, accent), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = Color.White, - contentColor = accent - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) - ) { - Text( - text = stringResource( - if (isTracking) R.string.remove_button else R.string.track_button - ).uppercase(Locale.ROOT), - style = MaterialTheme.typography.labelMedium - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun TrackButtonSectionPreview() { - CourseGrabTheme { - TrackButton( - isTracking = false, - onToggle = {}, - modifier = Modifier.height(26.dp) - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun TrackButtonRowPreview() { - CourseGrabTheme { - TrackButton( - isTracking = true, - onToggle = {}, - modifier = Modifier - .fillMaxWidth() - .height(44.dp) - ) - } -} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt new file mode 100644 index 0000000..81c0d44 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt @@ -0,0 +1,112 @@ +package com.cornellappdev.coursegrab.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.cornellappdev.coursegrab.R +import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme +import com.cornellappdev.coursegrab.ui.theme.RemovalRed +import java.util.Locale + +/** + * Starts tracking a section. See [OutlinedActionButton] for why these are not Material3 + * defaults. + * + * Size is the caller's job, because usages differ: the course rows give it the full row + * width at 44dp, while the section rows use 26dp and wrap. Pass both through [modifier]. + */ +@Composable +fun TrackButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + OutlinedActionButton( + label = R.string.track_button, + accent = Color.Black, + // track_button_background.xml + cornerRadius = 1.dp, + onClick = onClick, + modifier = modifier + ) +} + +/** Stops tracking a section. Sized by the caller, as [TrackButton] is. */ +@Composable +fun RemoveButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + OutlinedActionButton( + label = R.string.remove_button, + accent = RemovalRed, + // remove_button_background.xml — 3dp where track uses 1dp. + cornerRadius = 3.dp, + onClick = onClick, + modifier = modifier + ) +} + +/** + * The shape both buttons share: white fill and a 1dp outline in the accent color, matching + * the XML button drawables. A filled M3 Button looks nothing like the shipped app. + * + * Track and remove are separate composables rather than one with an `isTracking` flag, + * because most call sites know statically which one they want — the course rows on the + * tracked-courses screen only ever remove — and a boolean there reads as a toggle that + * could go either way. + */ +@Composable +private fun OutlinedActionButton( + @StringRes label: Int, + accent: Color, + cornerRadius: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(cornerRadius), + border = BorderStroke(1.dp, accent), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = accent + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text( + // US-only app: invariant casing keeps the locale out of composition. + text = stringResource(label).uppercase(Locale.ROOT), + style = MaterialTheme.typography.labelMedium + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TrackButtonPreview() { + CourseGrabTheme { + TrackButton(onClick = {}, modifier = Modifier.height(26.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun RemoveButtonPreview() { + CourseGrabTheme { + RemoveButton( + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + ) + } +} From 6c26dda043fe131319662e68480944073f2915ee Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 18:44:17 -0400 Subject: [PATCH 4/7] Remove comments --- .../ui/components/TrackingButtons.kt | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt index 81c0d44..70a4070 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/TrackingButtons.kt @@ -22,47 +22,28 @@ import com.cornellappdev.coursegrab.ui.theme.CourseGrabTheme import com.cornellappdev.coursegrab.ui.theme.RemovalRed import java.util.Locale -/** - * Starts tracking a section. See [OutlinedActionButton] for why these are not Material3 - * defaults. - * - * Size is the caller's job, because usages differ: the course rows give it the full row - * width at 44dp, while the section rows use 26dp and wrap. Pass both through [modifier]. - */ @Composable fun TrackButton(onClick: () -> Unit, modifier: Modifier = Modifier) { OutlinedActionButton( label = R.string.track_button, accent = Color.Black, - // track_button_background.xml cornerRadius = 1.dp, onClick = onClick, modifier = modifier ) } -/** Stops tracking a section. Sized by the caller, as [TrackButton] is. */ @Composable fun RemoveButton(onClick: () -> Unit, modifier: Modifier = Modifier) { OutlinedActionButton( label = R.string.remove_button, accent = RemovalRed, - // remove_button_background.xml — 3dp where track uses 1dp. cornerRadius = 3.dp, onClick = onClick, modifier = modifier ) } -/** - * The shape both buttons share: white fill and a 1dp outline in the accent color, matching - * the XML button drawables. A filled M3 Button looks nothing like the shipped app. - * - * Track and remove are separate composables rather than one with an `isTracking` flag, - * because most call sites know statically which one they want — the course rows on the - * tracked-courses screen only ever remove — and a boolean there reads as a toggle that - * could go either way. - */ @Composable private fun OutlinedActionButton( @StringRes label: Int, @@ -83,7 +64,6 @@ private fun OutlinedActionButton( contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) ) { Text( - // US-only app: invariant casing keeps the locale out of composition. text = stringResource(label).uppercase(Locale.ROOT), style = MaterialTheme.typography.labelMedium ) From 8a7fc66c69d4cab0817f01ff3c0ef7a17866dea8 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 19:00:20 -0400 Subject: [PATCH 5/7] Use a darker secondary text color on white surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onSurfaceVariant was LightText, which is 2.43:1 against the white surface — below WCAG AA's 4.5:1 for normal text. Its one consumer is EmptyState's subtitle, so every empty and error state on a white background was failing. Fixed by giving the role its own color rather than darkening LightText. LightText is also used directly on the dark surfaces — the search field in the black top bar and the notification modal over DarkEnough — where it measures 8.63:1 and 6.80:1. Darkening it to clear 4.5:1 on white would drop those to roughly 3.1:1 and 3.9:1, trading one failure for two. The two surfaces need two colors. Co-Authored-By: Claude Opus 5 --- .../java/com/cornellappdev/coursegrab/ui/theme/Color.kt | 8 ++++++++ .../java/com/cornellappdev/coursegrab/ui/theme/Theme.kt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt index cf81056..6c6decf 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt @@ -6,7 +6,15 @@ val Teal = Color(0xFF008577) val TealDark = Color(0xFF00574B) val Pink = Color(0xFFD81B60) val RemovalRed = Color(0xFFCA4238) +/** Secondary text on the dark surfaces — the search bar and the notification modal. */ val LightText = Color(0xFF9EA7B3) + +/** + * Secondary text on the white surfaces. [LightText] cannot serve here: it is 2.43:1 against + * white, below WCAG AA's 4.5:1 for normal text. This is 5.34:1. The two cannot be one color + * — anything dark enough for white falls below 4.5:1 on [DarkEnough]. + */ +val MutedText = Color(0xFF616C7A) val StatusGreen = Color(0xFF47C753) val DarkEnough = Color(0xFF1C1F23) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt index c6dbf40..e4d7b8d 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Theme.kt @@ -14,7 +14,7 @@ private val CourseGrabColorScheme = lightColorScheme( onBackground = Color.Black, surface = Color.White, onSurface = Color.Black, - onSurfaceVariant = LightText, + onSurfaceVariant = MutedText, error = RemovalRed, onError = Color.White, outline = LightText From bfc545901aa125b131f61fcfd8f57015e91440d3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 19:04:29 -0400 Subject: [PATCH 6/7] Remove added docstrings Co-Authored-By: Claude Opus 5 --- .../java/com/cornellappdev/coursegrab/ui/theme/Color.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt index 6c6decf..d0dec42 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/theme/Color.kt @@ -6,14 +6,7 @@ val Teal = Color(0xFF008577) val TealDark = Color(0xFF00574B) val Pink = Color(0xFFD81B60) val RemovalRed = Color(0xFFCA4238) -/** Secondary text on the dark surfaces — the search bar and the notification modal. */ val LightText = Color(0xFF9EA7B3) - -/** - * Secondary text on the white surfaces. [LightText] cannot serve here: it is 2.43:1 against - * white, below WCAG AA's 4.5:1 for normal text. This is 5.34:1. The two cannot be one color - * — anything dark enough for white falls below 4.5:1 on [DarkEnough]. - */ val MutedText = Color(0xFF616C7A) val StatusGreen = Color(0xFF47C753) val DarkEnough = Color(0xFF1C1F23) From 00e5b7147e061e63c534dff6eb8e93900f4d593d Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Tue, 22 Sep 2026 19:09:16 -0400 Subject: [PATCH 7/7] Make CourseRow's actions slot optional Defaulting to {} rendered a 44dp Row with 4dp of top padding for a course row with no actions. Nullable with a null default matches ListItem's trailingContent. Co-Authored-By: Claude Opus 5 --- .../coursegrab/ui/components/CourseRow.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt index 6c41058..29dac79 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/components/CourseRow.kt @@ -34,7 +34,7 @@ fun CourseRow( course: Course, onClick: () -> Unit, modifier: Modifier = Modifier, - actions: @Composable RowScope.() -> Unit = {} + actions: (@Composable RowScope.() -> Unit)? = null ) { OutlinedCard( modifier = modifier @@ -82,15 +82,17 @@ fun CourseRow( modifier = Modifier.padding(start = 12.dp) ) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - .height(BUTTON_ROW_HEIGHT), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - content = actions - ) + if (actions != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .height(BUTTON_ROW_HEIGHT), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } } } }