diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 832023a..66aff5a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,33 +1,19 @@ -name: Android CI +name: Android on: push: - branches: [ main ] pull_request: - branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17' - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v3 + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 with: - gradle-version: '8.9' - - - name: Generate Gradle wrapper (MVP bootstrap) - run: gradle wrapper --gradle-version 8.9 - - - name: Assemble debug - run: ./gradlew assembleDebug --no-daemon --stacktrace - - - name: Unit tests - run: ./gradlew test --no-daemon --stacktrace + gradle-version: '8.13' + - run: gradle test assembleDebug --stacktrace diff --git a/.gitignore b/.gitignore index 6cf5de8..b4e09c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,9 @@ -# Gradle +*.iml .gradle/ +.idea/ +local.properties build/ -!gradle/wrapper/gradle-wrapper.jar -gradle-app.setting -.gradletasknamecache - -# Android build outputs -app/build/ -app/release/ +*/build/ captures/ -.externalNativeBuild/ -.cxx/ -*.apk -*.aab -*.ap_ -*.dex -output.json - -# Local config -local.properties -keystore.properties -*.keystore -*.jks -*.p12 -*.pfx - -# IntelliJ / Android Studio -.idea/ -*.iml -*.ipr -*.iws -.navigation/ -.vscode/ - -# Kotlin / KSP / Hilt generated -**/generated/ -kotlin-js-store/ - -# OS .DS_Store -Thumbs.db -ehthumbs.db -Desktop.ini - -# Logs & crash dumps -*.log -hs_err_pid* -replay_pid* - -# Test + coverage artifacts -/reports/ -/app/reports/ -*.exec -jacoco.exec - -# Profiling -/profile/ -*.hprof - -# Credentials / secrets (never commit) -.env -.env.* -secrets.properties -google-services.json +app/release/ diff --git a/README.md b/README.md index 0dcff05..afa2c5d 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,80 @@ -# Outspire Android +# Outspire for Android -A native Android port of [Outspire](https://github.com/Computerization/Outspire) (iOS), built against the real TSIMS backend. +A fresh native Android implementation of [Computerization/Outspire](https://github.com/Computerization/Outspire). It uses the Swift project as its behavioural and visual specification and does not reuse the abandoned Android repository. -- **Status**: working demo against production TSIMS — login, timetable, scores, and CAS all hit live endpoints. -- **Stack**: Kotlin 2.0 · Jetpack Compose · Material 3 · Hilt · Navigation-Compose · Ktor (OkHttp engine) · kotlinx-serialization · kotlinx-datetime · Jsoup -- **minSdk**: 26 (Android 8.0) · **targetSdk**: 35 +## Current milestone -## Run it +This project provides a working application foundation: + +- Kotlin and Jetpack Compose UI with Material 3 +- feature-based MVVM structure +- four primary destinations: Today, Class, Activities, and Explore +- live one-second timetable progress and the original nine-period schedule +- weekday preview, self-study filling, subject colours, and quick links +- explicit demo mode with representative timetable, grades, and CAS records +- cookie-based TSIMS login and session verification +- TSIMS academic-year discovery and timetable decoding for both response formats used by the Swift app +- account, grades, CAS summary, dark theme, deep-link declaration, and unit tests + +Live score and CAS endpoints are deliberately isolated behind `OutspireRepository`; their Compose screens already consume the domain models. They can be connected without changing the UI. + +## Requirements + +- Android Studio compatible with Android Gradle Plugin 8.13 +- JDK 17 +- Android SDK 36 +- Gradle 8.13 +- Android 8.0 or later on the device (`minSdk 26`) + +The Gradle wrapper binary is omitted from this generated source archive. Generate it once with an installed Gradle: ```bash -./gradlew assembleDebug -./gradlew test +gradle wrapper --gradle-version 8.13 +./gradlew test assembleDebug ``` -Or open the project in Android Studio (Koala+), select `app`, and run on an emulator or device. - -## Implemented - -**Auth & session** -- TSIMS login with real credentials (`/Home/Login` → `/Home/GetMenu` session verify → `/Home/StudentInfo` profile scrape) -- EncryptedSharedPreferences for credentials + cookie jar + cached state -- Auto-retry on 302/401 via `AuthService.withAuthRetry` -- Live logout that clears cookies and navigates back to login reactively - -**Today** -- Live countdown to next lesson / break (1 Hz ticker) -- 5-day (Mon–Fri) week-at-a-glance grid under the countdown card -- Weekend placeholder copy - -**Academic (Scores)** -- `POST /Stu/Exam/GetScoreData` with term picker (`ExposedDropdownMenuBox`) -- iOS-parity score normalization (`"-"` → `"0"` for raw, `""` for IB) -- Per-subject card with T1–T5 chip rows - -**Settings** -- Real student ID + username from login profile -- Term picker wired to `YearRepository` (persists via `SecureCredentialStore`) -- Logout - -**CAS** -- 3-tab scaffold: **My Clubs** · **Browse** · **Evaluation** -- `GetMyGroupList` / `GetGroupList` (paginated infinite scroll over all ~100 clubs) / `JoinGroup` -- Club detail screen with intro card (group no / teacher / full description) + Records and Reflections sub-tabs -- `GetRecordList` / `GetReflectionList` read flow -- **Full write flow**: add / edit / delete records (with ≥80-word reflection validation, C/A/S duration inputs, date field) and reflections (title / summary / content / LO chip selector) via `SaveRecord` / `DeleteRecord` / `SaveReflection` / `DeleteReflection` -- `GetEvaluateData` semester breakdown (Rec / Ref / Talk / Final + per-club hours) - -**Year options** -- Scrapes `""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ).find(html)?.groupValues?.get(1).orEmpty() + Regex("""]*value=[\"']([^\"']+)[\"'][^>]*>(.*?)""", RegexOption.IGNORE_CASE) + .findAll(select) + .map { it.groupValues[1].trim() to it.groupValues[2].replace(Regex("<[^>]+>"), "").trim() } + .filter { it.first.isNotBlank() } + .toList() + } finally { + connection.disconnect() + } + } + + private suspend fun postForm(path: String, values: Map): JSONObject = + withContext(Dispatchers.IO) { + val connection = open(path, "POST") + val body = values.entries.joinToString("&") { (key, value) -> + "${key.encode()}=${value.encode()}" + } + try { + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + connection.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) } + val status = connection.responseCode + val text = connection.readBody(status) + check(status in 200..299) { "TSIMS returned HTTP $status." } + check(!isUnexpectedLoginRedirect(path, connection.url.path.orEmpty())) { + "TSIMS returned to the sign-in page. Check your credentials." + } + check(!connection.contentType.orEmpty().contains("text/html", ignoreCase = true)) { + "TSIMS returned an unexpected sign-in page." + } + check(text.isNotBlank()) { "TSIMS returned an empty response." } + JSONObject(text) + } finally { + connection.disconnect() + } + } + + private suspend fun postEmptyForm(path: String) = withContext(Dispatchers.IO) { + val connection = open(path, "POST") + try { + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + connection.outputStream.use { } + val status = connection.responseCode + connection.readBody(status) + check(status in 200..399) { "TSIMS returned HTTP $status while signing out." } + } finally { + connection.disconnect() + } + } + + private fun open(path: String, method: String, expectsJson: Boolean = true): HttpURLConnection { + val url = URI.create(root + path).toURL() + return (url.openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 15_000 + readTimeout = 15_000 + instanceFollowRedirects = true + if (expectsJson) { + setRequestProperty("Accept", "application/json, text/javascript, */*; q=0.01") + setRequestProperty("X-Requested-With", "XMLHttpRequest") + } else { + setRequestProperty("Accept", "text/html,application/xhtml+xml,*/*;q=0.8") + } + setRequestProperty("Referer", "$root/") + setRequestProperty("Origin", root) + setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + setRequestProperty( + "User-Agent", + "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36", + ) + } + } + + private fun HttpURLConnection.readBody(status: Int): String { + val stream = if (status in 200..399) { + runCatching { inputStream }.getOrNull() + } else { + errorStream + } + return stream?.bufferedReader()?.use { it.readText() }.orEmpty() + } + + private fun HttpURLConnection.landedOnLogin(): Boolean = + url.path.orEmpty().contains("/Home/Login", ignoreCase = true) + + private fun parseTimetable(data: Any?): List { + if (data is JSONArray) return parseSimpleArray(data) + val rootObject = data as? JSONObject ?: return emptyList() + val slots = rootObject.optJSONArray("TimetableList") ?: return emptyList() + return buildList { + for (slotIndex in 0 until slots.length()) { + val lessons = slots.optJSONObject(slotIndex)?.optJSONArray("TimetableList") ?: continue + for (index in 0 until lessons.length()) { + val lesson = lessons.optJSONObject(index) ?: continue + val day = dayFromNumber(lesson.optInt("WeekNumber")) ?: continue + add( + ScheduleEntry( + day = day, + period = lesson.optInt("LessonNumber"), + subject = lesson.optString("SubjectName", "Self-Study"), + room = lesson.optString("ClassRoomNo").ifBlank { null }, + teacher = lesson.optString("TeacherName").ifBlank { null }, + ), + ) + } + } + } + } + + private fun parseSimpleArray(array: JSONArray): List = buildList { + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val day = runCatching { DayOfWeek.valueOf(item.optString("Day").uppercase()) }.getOrNull() ?: continue + add( + ScheduleEntry( + day = day, + period = item.optInt("Period"), + subject = item.optString("Course", "Self-Study"), + room = item.optString("Room").ifBlank { null }, + teacher = item.optString("Teacher").ifBlank { null }, + ), + ) + } + } + + private fun dayFromNumber(number: Int): DayOfWeek? = when (number) { + 1 -> DayOfWeek.MONDAY + 2 -> DayOfWeek.TUESDAY + 3 -> DayOfWeek.WEDNESDAY + 4 -> DayOfWeek.THURSDAY + 5 -> DayOfWeek.FRIDAY + else -> null + } + + private fun JSONObject.resultIsSuccess(): Boolean = when (val result = opt("ResultType")) { + is Number -> result.toInt() == 0 + is String -> result == "0" + else -> false + } + + private fun JSONObject.optIntOrNull(key: String): Int? = + if (has(key) && !isNull(key)) optInt(key) else null + + private fun String.encode(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.name()) +} diff --git a/app/src/main/java/dev/outspire/android/data/remote/TsimsProfileParser.kt b/app/src/main/java/dev/outspire/android/data/remote/TsimsProfileParser.kt new file mode 100644 index 0000000..1f01a3e --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/remote/TsimsProfileParser.kt @@ -0,0 +1,68 @@ +package dev.outspire.android.data.remote + +import dev.outspire.android.data.model.User + +internal object TsimsProfileParser { + private val cellRegex = Regex( + """<(?:td|th)\b[^>]*>(.*?)""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) + + fun parse(html: String, fallback: User): User { + val id = inputValue(html, "UserId", "StudentId", "id")?.toIntOrNull() ?: fallback.id + val code = inputValue(html, "UserCode") + ?: tableValue(html, "\u5b66\u53f7", "UserCode", "Student Code") + ?: fallback.code + + val firstName = inputValue(html, "FirstName").orEmpty() + val lastName = inputValue(html, "LastName").orEmpty() + val combinedName = listOf(firstName, lastName) + .filter { it.isNotBlank() } + .joinToString(" ") + val name = inputValue(html, "Name") + ?: tableValue(html, "\u59d3\u540d", "Name") + ?: combinedName.ifBlank { null } + ?: fallback.name + + return fallback.copy(id = id, code = code, name = name) + } + + private fun inputValue(html: String, vararg names: String): String? { + for (name in names) { + val escapedName = Regex.escape(name) + val nameThenValue = Regex( + """]*\bname\s*=\s*["']$escapedName["'][^>]*\bvalue\s*=\s*["']([^"']*)["'][^>]*>""", + RegexOption.IGNORE_CASE, + ) + val valueThenName = Regex( + """]*\bvalue\s*=\s*["']([^"']*)["'][^>]*\bname\s*=\s*["']$escapedName["'][^>]*>""", + RegexOption.IGNORE_CASE, + ) + val value = nameThenValue.find(html)?.groupValues?.get(1) + ?: valueThenName.find(html)?.groupValues?.get(1) + cleanText(value.orEmpty()).takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + private fun tableValue(html: String, vararg labels: String): String? { + val cells = cellRegex.findAll(html) + .map { cleanText(it.groupValues[1]) } + .toList() + val index = cells.indexOfFirst { cell -> + labels.any { label -> cell.equals(label, ignoreCase = true) || cell.contains(label, ignoreCase = true) } + } + return cells.getOrNull(index + 1)?.takeIf { index >= 0 && it.isNotBlank() } + } + + private fun cleanText(value: String): String = value + .replace(Regex("<[^>]+>"), " ") + .replace(" ", " ", ignoreCase = true) + .replace("&", "&", ignoreCase = true) + .replace(""", "\"", ignoreCase = true) + .replace("'", "'", ignoreCase = true) + .replace("<", "<", ignoreCase = true) + .replace(">", ">", ignoreCase = true) + .replace(Regex("\\s+"), " ") + .trim() +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/CredentialRecorder.kt b/app/src/main/java/dev/outspire/android/data/repository/CredentialRecorder.kt new file mode 100644 index 0000000..93fe663 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/CredentialRecorder.kt @@ -0,0 +1,98 @@ +package dev.outspire.android.data.repository + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.nio.charset.StandardCharsets +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +data class RecordedCredentials( + val code: String, + val password: String, +) + +interface CredentialRecorder { + fun load(): RecordedCredentials? + fun save(credentials: RecordedCredentials) + fun clear() +} + +class AndroidCredentialRecorder(context: Context) : CredentialRecorder { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + override fun load(): RecordedCredentials? = runCatching { + val encrypted = preferences.getString(KEY_CIPHERTEXT, null) ?: return null + val iv = preferences.getString(KEY_IV, null) ?: return null + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init( + Cipher.DECRYPT_MODE, + encryptionKey(), + GCMParameterSpec(TAG_LENGTH_BITS, Base64.decode(iv, Base64.NO_WRAP)), + ) + } + val plaintext = String( + cipher.doFinal(Base64.decode(encrypted, Base64.NO_WRAP)), + StandardCharsets.UTF_8, + ) + val separator = plaintext.indexOf(SEPARATOR) + check(separator > 0) { "Saved credentials are malformed." } + RecordedCredentials( + code = plaintext.substring(0, separator), + password = plaintext.substring(separator + 1), + ) + }.getOrElse { + clear() + null + } + + override fun save(credentials: RecordedCredentials) { + val plaintext = "${credentials.code}$SEPARATOR${credentials.password}" + .toByteArray(StandardCharsets.UTF_8) + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, encryptionKey()) + } + val encrypted = cipher.doFinal(plaintext) + preferences.edit() + .putString(KEY_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .putString(KEY_CIPHERTEXT, Base64.encodeToString(encrypted, Base64.NO_WRAP)) + .apply() + } + + override fun clear() { + preferences.edit().clear().apply() + } + + private fun encryptionKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + private companion object { + const val PREFERENCES_NAME = "recorded-credentials" + const val KEY_IV = "iv" + const val KEY_CIPHERTEXT = "ciphertext" + const val KEYSTORE_PROVIDER = "AndroidKeyStore" + const val KEY_ALIAS = "outspire-recorded-credentials" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val TAG_LENGTH_BITS = 128 + const val SEPARATOR = '\u0000' + } +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/DefaultOutspireRepository.kt b/app/src/main/java/dev/outspire/android/data/repository/DefaultOutspireRepository.kt new file mode 100644 index 0000000..5bec0b8 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/DefaultOutspireRepository.kt @@ -0,0 +1,74 @@ +package dev.outspire.android.data.repository + +import dev.outspire.android.data.model.CasActivity +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.SubjectScore +import dev.outspire.android.data.model.User +import dev.outspire.android.data.remote.TsimsDataSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DefaultOutspireRepository( + private val client: TsimsDataSource, +) : OutspireRepository { + private val mutableSession = MutableStateFlow(null) + override val session: StateFlow = mutableSession.asStateFlow() + private var cachedSchedule: List? = null + + override suspend fun login(code: String, password: String): Result { + if (code.isBlank() || password.isBlank()) { + return Result.failure(IllegalArgumentException("Enter both your student code and password.")) + } + cachedSchedule = null + mutableSession.value = null + return client.login(code.trim(), password).onSuccess { mutableSession.value = it } + } + + override fun enterDemoMode() { + client.clearSession() + cachedSchedule = null + mutableSession.value = User( + id = null, + code = "demo", + name = "Outspire Student", + role = "Student", + isDemo = true, + ) + } + + override suspend fun logout() { + try { + client.logout() + } finally { + cachedSchedule = null + mutableSession.value = null + } + } + + override suspend fun loadSchedule(forceRefresh: Boolean): Result> { + val user = session.value ?: return Result.failure(IllegalStateException("Sign in to view your timetable.")) + if (!forceRefresh) cachedSchedule?.let { return Result.success(it) } + val result = if (user.isDemo) Result.success(DemoData.schedule) else client.loadTimetable(user) + result.onSuccess { cachedSchedule = it } + return result + } + + override suspend fun loadScores(): Result> { + val user = session.value ?: return Result.failure(IllegalStateException("Sign in to view scores.")) + return if (user.isDemo) { + Result.success(DemoData.scores) + } else { + Result.failure(NotImplementedError("Live score migration is scheduled for the next milestone.")) + } + } + + override suspend fun loadActivities(): Result> { + val user = session.value ?: return Result.failure(IllegalStateException("Sign in to view activities.")) + return if (user.isDemo) { + Result.success(DemoData.activities) + } else { + Result.failure(NotImplementedError("Live CAS migration is scheduled for the next milestone.")) + } + } +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/DemoData.kt b/app/src/main/java/dev/outspire/android/data/repository/DemoData.kt new file mode 100644 index 0000000..80d9b85 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/DemoData.kt @@ -0,0 +1,67 @@ +package dev.outspire.android.data.repository + +import dev.outspire.android.data.model.CasActivity +import dev.outspire.android.data.model.CasCategory +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.SchoolWeek +import dev.outspire.android.data.model.SubjectScore +import java.time.DayOfWeek +import java.time.LocalDate + +internal object DemoData { + val schedule = buildList { + val subjects = listOf( + Triple("Mathematics AA HL", "Ms Chen", "A401"), + Triple("English B HL", "Mr Smith", "B302"), + Triple("Chemistry SL", "Dr Wang", "Lab 2"), + Triple("Physics HL", "Mr Liu", "Lab 1"), + Triple("Economics HL", "Ms Zhao", "C205"), + Triple("Chinese A SL", "Ms Li", "B104"), + ) + for (day in SchoolWeek.days) { + for (period in 1..9) { + if (period == 5 && day == DayOfWeek.WEDNESDAY) continue + val value = subjects[(period + day.value) % subjects.size] + add(ScheduleEntry(day, period, value.first, value.second, value.third)) + } + } + } + + val scores = listOf( + SubjectScore("Mathematics AA HL", "Term 2", "91", "7"), + SubjectScore("English B HL", "Term 2", "88", "7"), + SubjectScore("Chemistry SL", "Term 2", "86", "6"), + SubjectScore("Physics HL", "Term 2", "93", "7"), + SubjectScore("Economics HL", "Term 2", "84", "6"), + ) + + val activities = listOf( + CasActivity( + id = "1", + title = "Sub-culture Week planning", + club = "Doki-doki ACGN", + date = LocalDate.now().minusDays(8), + categories = setOf(CasCategory.CREATIVITY, CasCategory.SERVICE), + hours = 5, + reflectionComplete = true, + ), + CasActivity( + id = "2", + title = "Model evaluation workshop", + club = "AI-Lab", + date = LocalDate.now().minusDays(18), + categories = setOf(CasCategory.CREATIVITY), + hours = 3, + reflectionComplete = false, + ), + CasActivity( + id = "3", + title = "Campus technology support", + club = "Computerization", + date = LocalDate.now().minusDays(27), + categories = setOf(CasCategory.SERVICE), + hours = 4, + reflectionComplete = true, + ), + ) +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/OutspireRepository.kt b/app/src/main/java/dev/outspire/android/data/repository/OutspireRepository.kt new file mode 100644 index 0000000..0b7f558 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/OutspireRepository.kt @@ -0,0 +1,18 @@ +package dev.outspire.android.data.repository + +import dev.outspire.android.data.model.CasActivity +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.SubjectScore +import dev.outspire.android.data.model.User +import kotlinx.coroutines.flow.StateFlow + +interface OutspireRepository { + val session: StateFlow + + suspend fun login(code: String, password: String): Result + fun enterDemoMode() + suspend fun logout() + suspend fun loadSchedule(forceRefresh: Boolean = false): Result> + suspend fun loadScores(): Result> + suspend fun loadActivities(): Result> +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/ScheduleSettingsStore.kt b/app/src/main/java/dev/outspire/android/data/repository/ScheduleSettingsStore.kt new file mode 100644 index 0000000..5e4858c --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/ScheduleSettingsStore.kt @@ -0,0 +1,82 @@ +package dev.outspire.android.data.repository + +import android.content.Context +import dev.outspire.android.data.model.ScheduleSettings +import dev.outspire.android.data.model.SchoolTime +import java.time.DayOfWeek +import java.time.LocalDate +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class ScheduleSettingsStore(context: Context) { + private val preferences = context.getSharedPreferences("schedule-settings", Context.MODE_PRIVATE) + private var activeAccount: String? = null + private val mutableSettings = MutableStateFlow(ScheduleSettings()) + val settings: StateFlow = mutableSettings.asStateFlow() + + fun selectAccount(code: String?) { + val account = code?.trim()?.lowercase()?.takeIf(String::isNotEmpty) + if (activeAccount == account) return + activeAccount = account + mutableSettings.value = account?.let(::readSettings) ?: ScheduleSettings() + } + + fun setDayOverride(day: DayOfWeek?) = update { it.copy(dayOverride = day) } + + fun setHolidayEnabled(enabled: Boolean) = update { it.copy(holidayEnabled = enabled) } + + fun setHolidayEndDateEnabled(enabled: Boolean) = update { + it.copy(holidayEndDateEnabled = enabled) + } + + fun setHolidayEndDate(date: LocalDate) = update { it.copy(holidayEndDate = date) } + + fun setShowFutureCountdown(enabled: Boolean) = update { + it.copy(showFutureCountdown = enabled) + } + + private fun update(transform: (ScheduleSettings) -> ScheduleSettings) { + if (activeAccount == null) return + mutableSettings.update(transform) + persist(activeAccount.orEmpty(), mutableSettings.value) + } + + private fun readSettings(account: String): ScheduleSettings { + val day = preferences.getString(key(account, KEY_DAY_OVERRIDE), null) + ?.let { value -> runCatching { DayOfWeek.valueOf(value) }.getOrNull() } + val storedEndDate = preferences.getLong(key(account, KEY_HOLIDAY_END_DATE), Long.MIN_VALUE) + return ScheduleSettings( + dayOverride = day, + holidayEnabled = preferences.getBoolean(key(account, KEY_HOLIDAY_ENABLED), false), + holidayEndDateEnabled = preferences.getBoolean(key(account, KEY_HOLIDAY_END_ENABLED), false), + holidayEndDate = if (storedEndDate == Long.MIN_VALUE) { + SchoolTime.now().toLocalDate().plusMonths(1) + } else { + LocalDate.ofEpochDay(storedEndDate) + }, + showFutureCountdown = preferences.getBoolean(key(account, KEY_FUTURE_COUNTDOWN), true), + ) + } + + private fun persist(account: String, settings: ScheduleSettings) { + preferences.edit() + .putString(key(account, KEY_DAY_OVERRIDE), settings.dayOverride?.name) + .putBoolean(key(account, KEY_HOLIDAY_ENABLED), settings.holidayEnabled) + .putBoolean(key(account, KEY_HOLIDAY_END_ENABLED), settings.holidayEndDateEnabled) + .putLong(key(account, KEY_HOLIDAY_END_DATE), settings.holidayEndDate.toEpochDay()) + .putBoolean(key(account, KEY_FUTURE_COUNTDOWN), settings.showFutureCountdown) + .apply() + } + + private fun key(account: String, setting: String): String = "account:$account:$setting" + + private companion object { + const val KEY_DAY_OVERRIDE = "day-override" + const val KEY_HOLIDAY_ENABLED = "holiday-enabled" + const val KEY_HOLIDAY_END_ENABLED = "holiday-end-enabled" + const val KEY_HOLIDAY_END_DATE = "holiday-end-date" + const val KEY_FUTURE_COUNTDOWN = "future-countdown" + } +} diff --git a/app/src/main/java/dev/outspire/android/data/repository/ServiceLocator.kt b/app/src/main/java/dev/outspire/android/data/repository/ServiceLocator.kt new file mode 100644 index 0000000..01fec67 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/data/repository/ServiceLocator.kt @@ -0,0 +1,10 @@ +package dev.outspire.android.data.repository + +import dev.outspire.android.BuildConfig +import dev.outspire.android.data.remote.TsimsClient + +object ServiceLocator { + val repository: OutspireRepository by lazy { + DefaultOutspireRepository(TsimsClient(BuildConfig.TSIMS_BASE_URL)) + } +} diff --git a/app/src/main/java/dev/outspire/android/designsystem/Components.kt b/app/src/main/java/dev/outspire/android/designsystem/Components.kt new file mode 100644 index 0000000..501e62e --- /dev/null +++ b/app/src/main/java/dev/outspire/android/designsystem/Components.kt @@ -0,0 +1,147 @@ +package dev.outspire.android.designsystem + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun OutspireBackground( + modifier: Modifier = Modifier, + colors: List = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.17f), + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.09f), + MaterialTheme.colorScheme.background, + ), + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Brush.verticalGradient(colors)), + ) { + content() + } +} + +@Composable +fun RichCard( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) { + Box(Modifier.padding(AppSpace.cardPadding)) { content() } + } +} + +@Composable +fun GradientCard( + colors: List, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AppRadius.card)) + .background(Brush.linearGradient(colors)) + .padding(AppSpace.cardPadding), + ) { + content() + } +} + +@Composable +fun ScreenTitle( + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.headlineMedium) + subtitle?.let { + Text( + it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + actions() + } +} + +@Composable +fun StatusPill( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(color.copy(alpha = 0.14f)) + .padding(horizontal = 10.dp, vertical = 5.dp), + color = color, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) +} + +@Composable +fun LoadingCard(message: String = "Loading…") { + RichCard { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpace.md), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + Text(message) + } + } +} + +@Composable +fun ErrorCard(message: String) { + RichCard(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f)) { + Text( + message, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/app/src/main/java/dev/outspire/android/designsystem/Theme.kt b/app/src/main/java/dev/outspire/android/designsystem/Theme.kt new file mode 100644 index 0000000..e9d40f0 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/designsystem/Theme.kt @@ -0,0 +1,106 @@ +package dev.outspire.android.designsystem + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +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 + +private val Brand = androidx.compose.ui.graphics.Color(0xFF6650A4) +private val BrandDark = androidx.compose.ui.graphics.Color(0xFFD0BCFF) +private val RichDarkBackground = androidx.compose.ui.graphics.Color(0xFF0F0F17) +private val RichDarkSurface = androidx.compose.ui.graphics.Color(0xFF191923) + +private val LightColors = lightColorScheme( + primary = Brand, + secondary = androidx.compose.ui.graphics.Color(0xFF52606D), + tertiary = androidx.compose.ui.graphics.Color(0xFF006D75), + background = androidx.compose.ui.graphics.Color(0xFFF8F7FC), + surface = androidx.compose.ui.graphics.Color(0xFFFFFBFF), + surfaceVariant = androidx.compose.ui.graphics.Color(0xFFE8E5EE), +) + +private val DarkColors = darkColorScheme( + primary = BrandDark, + secondary = androidx.compose.ui.graphics.Color(0xFFBEC6D0), + tertiary = androidx.compose.ui.graphics.Color(0xFF82D3DB), + background = RichDarkBackground, + surface = RichDarkSurface, + surfaceVariant = androidx.compose.ui.graphics.Color(0xFF22222E), +) + +private val OutspireTypography = Typography( + displaySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 42.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ), +) + +@Composable +fun OutspireTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colors = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colors, + typography = OutspireTypography, + shapes = Shapes( + extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.sm), + small = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md), + medium = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.lg), + large = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.card), + extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.xl), + ), + content = content, + ) +} diff --git a/app/src/main/java/com/computerization/outspire/designsystem/Tokens.kt b/app/src/main/java/dev/outspire/android/designsystem/Tokens.kt similarity index 87% rename from app/src/main/java/com/computerization/outspire/designsystem/Tokens.kt rename to app/src/main/java/dev/outspire/android/designsystem/Tokens.kt index 2cb1f49..a66b057 100644 --- a/app/src/main/java/com/computerization/outspire/designsystem/Tokens.kt +++ b/app/src/main/java/dev/outspire/android/designsystem/Tokens.kt @@ -1,4 +1,4 @@ -package com.computerization.outspire.designsystem +package dev.outspire.android.designsystem import androidx.compose.ui.unit.dp @@ -10,7 +10,6 @@ object AppSpace { val lg = 20.dp val xl = 24.dp val xxl = 32.dp - val cardPadding = 20.dp val cardSpacing = 16.dp } diff --git a/app/src/main/java/dev/outspire/android/feature/ViewModelFactory.kt b/app/src/main/java/dev/outspire/android/feature/ViewModelFactory.kt new file mode 100644 index 0000000..fa04808 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/ViewModelFactory.kt @@ -0,0 +1,11 @@ +package dev.outspire.android.feature + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class ViewModelFactory( + private val initializer: () -> VM, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = initializer() as T +} diff --git a/app/src/main/java/dev/outspire/android/feature/academic/AcademicScreen.kt b/app/src/main/java/dev/outspire/android/feature/academic/AcademicScreen.kt new file mode 100644 index 0000000..17d8731 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/academic/AcademicScreen.kt @@ -0,0 +1,189 @@ +package dev.outspire.android.feature.academic + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.outspire.android.data.model.ScheduleResolver +import dev.outspire.android.data.model.SchoolPeriods +import dev.outspire.android.data.model.SchoolWeek +import dev.outspire.android.data.model.User +import dev.outspire.android.designsystem.AppSpace +import dev.outspire.android.designsystem.ErrorCard +import dev.outspire.android.designsystem.LoadingCard +import dev.outspire.android.designsystem.RichCard +import dev.outspire.android.designsystem.ScreenTitle +import java.time.DayOfWeek +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun AcademicScreen( + state: AcademicUiState, + user: User?, + onSignIn: () -> Unit, + onRefresh: () -> Unit, + onSelectDay: (DayOfWeek) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + Spacer(Modifier.size(AppSpace.xs)) + ScreenTitle("Classes", "Your weekly timetable") { + if (user != null) { + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh timetable") + } + } + } + + if (user == null) { + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + Icon(Icons.Default.Login, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("Sign in to load your timetable.", style = MaterialTheme.typography.titleMedium) + Button(onClick = onSignIn) { Text("Open account") } + } + } + return@Column + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (day in SchoolWeek.days) { + FilterChip( + selected = day == state.selectedDay, + onClick = { onSelectDay(day) }, + label = { Text(day.getDisplayName(TextStyle.SHORT, Locale.ENGLISH).take(2)) }, + modifier = Modifier.weight(1f), + ) + } + } + + when { + state.isLoading && state.schedule.isEmpty() -> LoadingCard("Loading classes…") + state.scheduleError != null && state.schedule.isEmpty() -> ErrorCard(state.scheduleError) + else -> { + val entries = ScheduleResolver.daySchedule(state.schedule, state.selectedDay) + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + entries.forEach { entry -> + val period = SchoolPeriods.all.first { it.number == entry.period } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(AppSpace.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(34.dp) + .background( + if (entry.isSelfStudy) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primaryContainer, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text(entry.period.toString(), style = MaterialTheme.typography.labelLarge) + } + Column(Modifier.weight(1f)) { + Text( + entry.subject, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull(entry.teacher, entry.room).joinToString(" · ").ifBlank { "Independent study" }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + period.start.format(DateTimeFormatter.ofPattern("H:mm")), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + state.scheduleError?.takeIf { state.schedule.isNotEmpty() }?.let { ErrorCard(it) } + Spacer(Modifier.size(88.dp)) + } +} + +@Composable +fun ScoresScreen( + state: AcademicUiState, + user: User?, + onSignIn: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + ScreenTitle("Grades", "Academic results") + if (user == null) { + ErrorCard("Sign in to view academic results.") + Button(onClick = onSignIn) { Text("Open account") } + return@Column + } + if (state.isLoading && state.scores.isEmpty()) LoadingCard("Loading grades…") + state.scoreError?.let { ErrorCard(it) } + state.scores.forEach { result -> + RichCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(result.subject, style = MaterialTheme.typography.titleMedium) + Text(result.term, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column(horizontalAlignment = Alignment.End) { + Text(result.score, style = MaterialTheme.typography.headlineMedium) + result.grade?.let { Text("IB grade $it", color = Color(0xFF2E7D32)) } + } + } + } + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/academic/AcademicViewModel.kt b/app/src/main/java/dev/outspire/android/feature/academic/AcademicViewModel.kt new file mode 100644 index 0000000..5b05d38 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/academic/AcademicViewModel.kt @@ -0,0 +1,52 @@ +package dev.outspire.android.feature.academic + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.SubjectScore +import dev.outspire.android.data.repository.OutspireRepository +import java.time.DayOfWeek +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AcademicUiState( + val isLoading: Boolean = false, + val schedule: List = emptyList(), + val scores: List = emptyList(), + val selectedDay: DayOfWeek = DayOfWeek.MONDAY, + val scheduleError: String? = null, + val scoreError: String? = null, +) + +class AcademicViewModel( + private val repository: OutspireRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(AcademicUiState()) + val state: StateFlow = mutableState.asStateFlow() + + fun selectDay(day: DayOfWeek) = mutableState.update { it.copy(selectedDay = day) } + + fun load(forceRefresh: Boolean = false) { + if (mutableState.value.isLoading) return + viewModelScope.launch { + mutableState.update { it.copy(isLoading = true, scheduleError = null, scoreError = null) } + val schedule = async { repository.loadSchedule(forceRefresh) } + val scores = async { repository.loadScores() } + val scheduleResult = schedule.await() + val scoreResult = scores.await() + mutableState.update { old -> + old.copy( + isLoading = false, + schedule = scheduleResult.getOrDefault(old.schedule), + scores = scoreResult.getOrDefault(old.scores), + scheduleError = scheduleResult.exceptionOrNull()?.message, + scoreError = scoreResult.exceptionOrNull()?.message, + ) + } + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/account/AccountScreen.kt b/app/src/main/java/dev/outspire/android/feature/account/AccountScreen.kt new file mode 100644 index 0000000..6df1941 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/account/AccountScreen.kt @@ -0,0 +1,166 @@ +package dev.outspire.android.feature.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import dev.outspire.android.data.model.User +import dev.outspire.android.designsystem.AppSpace +import dev.outspire.android.designsystem.ErrorCard +import dev.outspire.android.designsystem.RichCard +import dev.outspire.android.designsystem.ScreenTitle + +@Composable +fun AccountScreen( + state: AccountUiState, + user: User?, + onCodeChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onLogin: () -> Unit, + onDemo: () -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier, +) { + val passwordFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + ScreenTitle( + title = if (user == null) "Account" else "Profile", + subtitle = if (user == null) "Connect to your WFLA campus account" else "Signed in to Outspire", + ) + + if (user != null) { + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + Icon(Icons.Default.Person, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(user.name, style = MaterialTheme.typography.titleLarge) + Text( + if (user.isDemo) "Demo profile" else "Student code ${user.code}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading, + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Sign out") + } + } + } + } + return@Column + } + + state.error?.let { ErrorCard(it) } + + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + OutlinedTextField( + value = state.code, + onValueChange = onCodeChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Student code") }, + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { passwordFocusRequester.requestFocus() }, + ), + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(passwordFocusRequester), + label = { Text("Password") }, + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + if (!state.isLoading) onLogin() + }, + ), + ) + Button( + onClick = onLogin, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading, + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Sign in") + } + } + } + } + + Text( + "Live mode uses the TSIMS address configured on this device. Credentials are held only in memory in this milestone.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton(onClick = onDemo, modifier = Modifier.fillMaxWidth()) { + Text("Continue with demo data") + } + Spacer(Modifier.height(72.dp)) + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/account/AccountViewModel.kt b/app/src/main/java/dev/outspire/android/feature/account/AccountViewModel.kt new file mode 100644 index 0000000..1462858 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/account/AccountViewModel.kt @@ -0,0 +1,63 @@ +package dev.outspire.android.feature.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.outspire.android.data.model.User +import dev.outspire.android.data.repository.OutspireRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AccountUiState( + val code: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null, +) + +class AccountViewModel( + private val repository: OutspireRepository, +) : ViewModel() { + val session: StateFlow = repository.session + + private val mutableState = MutableStateFlow(AccountUiState()) + val state: StateFlow = mutableState.asStateFlow() + + fun setCode(value: String) = mutableState.update { it.copy(code = value, error = null) } + fun setPassword(value: String) = mutableState.update { it.copy(password = value, error = null) } + + fun login(onSuccess: () -> Unit) { + val current = state.value + if (current.isLoading) return + viewModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + repository.login(current.code, current.password) + .onSuccess { + mutableState.value = AccountUiState() + onSuccess() + } + .onFailure { failure -> + mutableState.update { + it.copy(isLoading = false, error = failure.message ?: "Login failed.") + } + } + } + } + + fun enterDemo(onSuccess: () -> Unit) { + repository.enterDemoMode() + onSuccess() + } + + fun logout(onComplete: () -> Unit) { + if (state.value.isLoading) return + viewModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + repository.logout() + mutableState.value = AccountUiState() + onComplete() + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesScreen.kt b/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesScreen.kt new file mode 100644 index 0000000..33ae904 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesScreen.kt @@ -0,0 +1,132 @@ +package dev.outspire.android.feature.activities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.unit.dp +import dev.outspire.android.data.model.CasActivity +import dev.outspire.android.data.model.CasCategory +import dev.outspire.android.data.model.User +import dev.outspire.android.designsystem.AppSpace +import dev.outspire.android.designsystem.ErrorCard +import dev.outspire.android.designsystem.GradientCard +import dev.outspire.android.designsystem.LoadingCard +import dev.outspire.android.designsystem.RichCard +import dev.outspire.android.designsystem.ScreenTitle +import dev.outspire.android.designsystem.StatusPill +import java.time.format.DateTimeFormatter + +@Composable +fun ActivitiesScreen( + state: ActivitiesUiState, + user: User?, + onSignIn: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + Spacer(Modifier.padding(top = AppSpace.xs)) + ScreenTitle("Activities", "Creativity, activity, and service") + + if (user == null) { + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + Icon(Icons.Default.Login, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("Sign in to see your CAS records.", style = MaterialTheme.typography.titleMedium) + Button(onClick = onSignIn) { Text("Open account") } + } + } + return@Column + } + + if (state.activities.isNotEmpty()) { + GradientCard( + colors = listOf(Color(0xFF2E7D5A), Color(0xFF365F9D)), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Column { + Text("Recorded CAS time", color = Color.White.copy(alpha = 0.8f)) + Text( + "${state.activities.sumOf(CasActivity::hours)} hours", + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + ) + } + Text( + "${state.activities.count(CasActivity::reflectionComplete)}/${state.activities.size} reflected", + color = Color.White, + ) + } + } + } + + when { + state.isLoading && state.activities.isEmpty() -> LoadingCard("Loading CAS records…") + state.error != null && state.activities.isEmpty() -> ErrorCard(state.error) + else -> state.activities.forEach { ActivityCard(it) } + } + state.error?.takeIf { state.activities.isNotEmpty() }?.let { ErrorCard(it) } + Spacer(Modifier.padding(bottom = 88.dp)) + } +} + +@Composable +private fun ActivityCard(activity: CasActivity) { + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f)) { + Text(activity.title, style = MaterialTheme.typography.titleMedium) + Text( + "${activity.club} · ${activity.date.format(DateTimeFormatter.ofPattern("d MMM yyyy"))}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text("${activity.hours} h", style = MaterialTheme.typography.titleLarge) + } + Row(horizontalArrangement = Arrangement.spacedBy(AppSpace.xs)) { + activity.categories.forEach { category -> + val (label, color) = when (category) { + CasCategory.CREATIVITY -> "C" to Color(0xFF9C3A65) + CasCategory.ACTIVITY -> "A" to Color(0xFF3B7C4B) + CasCategory.SERVICE -> "S" to Color(0xFF356CA5) + } + StatusPill(label, color) + } + StatusPill( + if (activity.reflectionComplete) "Reflected" else "Reflection needed", + if (activity.reflectionComplete) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesViewModel.kt b/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesViewModel.kt new file mode 100644 index 0000000..efe7c71 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/activities/ActivitiesViewModel.kt @@ -0,0 +1,38 @@ +package dev.outspire.android.feature.activities + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.outspire.android.data.model.CasActivity +import dev.outspire.android.data.repository.OutspireRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ActivitiesUiState( + val isLoading: Boolean = false, + val activities: List = emptyList(), + val error: String? = null, +) + +class ActivitiesViewModel( + private val repository: OutspireRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(ActivitiesUiState()) + val state: StateFlow = mutableState.asStateFlow() + + fun load() { + if (mutableState.value.isLoading) return + viewModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + repository.loadActivities() + .onSuccess { data -> mutableState.value = ActivitiesUiState(activities = data) } + .onFailure { failure -> + mutableState.update { + it.copy(isLoading = false, error = failure.message ?: "Unable to load CAS records.") + } + } + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/explore/ExploreScreen.kt b/app/src/main/java/dev/outspire/android/feature/explore/ExploreScreen.kt new file mode 100644 index 0000000..0a08fe0 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/explore/ExploreScreen.kt @@ -0,0 +1,170 @@ +package dev.outspire.android.feature.explore + +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.filled.Badge +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.Icon +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import dev.outspire.android.data.model.User +import dev.outspire.android.designsystem.AppRadius +import dev.outspire.android.designsystem.AppSpace +import dev.outspire.android.designsystem.RichCard +import dev.outspire.android.designsystem.ScreenTitle + +@Composable +fun ExploreScreen( + user: User?, + onToday: () -> Unit, + onClasses: () -> Unit, + onActivities: () -> Unit, + onGrades: () -> Unit, + onAccount: () -> Unit, + onPlaceholder: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + Spacer(Modifier.padding(top = AppSpace.xs)) + ScreenTitle("Explore", "Campus tools and information") + + Row(horizontalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + ExploreTile( + "Today", + Icons.Default.Today, + listOf(Color(0xFF6650A4), Color(0xFF8068B1)), + onToday, + Modifier.weight(1f), + ) + ExploreTile( + "Classes", + Icons.Default.CalendarMonth, + listOf(Color(0xFF3F51B5), Color(0xFF6573C3)), + onClasses, + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + ExploreTile( + "Activities", + Icons.Default.Badge, + listOf(Color(0xFF2E7D5A), Color(0xFF4B9473)), + onActivities, + Modifier.weight(1f), + ) + ExploreTile( + "Grades", + Icons.Default.School, + listOf(Color(0xFFCF6C25), Color(0xFFDF8A4D)), + onGrades, + Modifier.weight(1f), + ) + } + + RichCard { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.xs)) { + ExploreRow("School Arrangements", Icons.Default.CalendarMonth) { onPlaceholder("School Arrangements") } + ExploreRow("Dining Menus", Icons.Default.Restaurant) { onPlaceholder("Dining Menus") } + ExploreRow("Hall of Clubs", Icons.Default.Groups) { onPlaceholder("Hall of Clubs") } + ExploreRow("Reflections", Icons.Default.MenuBook) { onPlaceholder("Reflections") } + ExploreRow(if (user == null) "Account" else user.name, Icons.Default.Person, onAccount) + ExploreRow("Settings", Icons.Default.Settings) { onPlaceholder("Settings") } + } + } + Spacer(Modifier.padding(bottom = 88.dp)) + } +} + +@Composable +private fun ExploreTile( + label: String, + icon: ImageVector, + colors: List, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(Brush.linearGradient(colors), RoundedCornerShape(AppRadius.lg)) + .clickable(onClick = onClick) + .padding(AppSpace.md), + verticalArrangement = Arrangement.spacedBy(AppSpace.lg), + ) { + Icon(icon, contentDescription = null, tint = Color.White) + Text(label, color = Color.White, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun ExploreRow( + label: String, + icon: ImageVector, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = AppSpace.sm), + horizontalArrangement = Arrangement.spacedBy(AppSpace.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +fun PlaceholderScreen(title: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.md), + ) { + ScreenTitle(title) + RichCard { + Text( + "This feature is defined in the migration plan and will be connected to its original Swift implementation in a later milestone.", + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/today/TodayScreen.kt b/app/src/main/java/dev/outspire/android/feature/today/TodayScreen.kt new file mode 100644 index 0000000..0bf3b4b --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/today/TodayScreen.kt @@ -0,0 +1,372 @@ +package dev.outspire.android.feature.today + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.School +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.outspire.android.data.model.PeriodPhase +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.ScheduleResolver +import dev.outspire.android.data.model.TimelinePeriod +import dev.outspire.android.data.model.User +import dev.outspire.android.designsystem.AppRadius +import dev.outspire.android.designsystem.AppSpace +import dev.outspire.android.designsystem.ErrorCard +import dev.outspire.android.designsystem.GradientCard +import dev.outspire.android.designsystem.LoadingCard +import dev.outspire.android.designsystem.ScreenTitle +import dev.outspire.android.designsystem.StatusPill +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun TodayScreen( + state: TodayUiState, + user: User?, + onSignIn: () -> Unit, + onRefresh: () -> Unit, + onClasses: () -> Unit, + onActivities: () -> Unit, + onGrades: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + Spacer(Modifier.height(AppSpace.xs)) + ScreenTitle( + title = greeting(state.now.hour), + subtitle = state.now.format( + DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.ENGLISH), + ), + ) { + if (user != null) { + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh timetable") + } + } + } + + if (user == null) { + SignInPrompt(onSignIn) + QuickLinks(onClasses, onActivities, onGrades) + Spacer(Modifier.height(88.dp)) + return@Column + } + + if (user.isDemo) { + StatusPill("Demo data", MaterialTheme.colorScheme.tertiary) + } + + when { + state.isLoading && state.schedule.isEmpty() -> LoadingCard("Loading your timetable...") + state.error != null && state.schedule.isEmpty() -> ErrorCard(state.error) + else -> ScheduleCard(state) + } + + QuickLinks(onClasses, onActivities, onGrades) + state.error?.takeIf { state.schedule.isNotEmpty() }?.let { ErrorCard(it) } + Spacer(Modifier.height(88.dp)) + } +} + +@Composable +private fun SignInPrompt(onSignIn: () -> Unit) { + GradientCard( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ) { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + Icon(Icons.Default.Login, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp)) + Text("Your school day, in one place", style = MaterialTheme.typography.titleLarge, color = Color.White) + Text( + "Sign in to see live classes, scores, and CAS records, or use the included demo profile.", + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.86f), + ) + Button(onClick = onSignIn) { Text("Open account") } + } + } +} + +@Composable +private fun ScheduleCard(state: TodayUiState) { + val timeline = ScheduleResolver.todayTimeline(state.schedule, state.now) + if (!timeline.hasRemainingClasses) { + NoClassesCard() + return + } + + val dayName = state.now.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.ENGLISH) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + border = BorderStroke( + 0.5.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), + ) { + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.horizontalGradient( + listOf(Color(0xFF45CC70), Color(0xFF13AFA5)), + ), + ) + .padding(AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + "$dayName's Schedule", + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + Text( + classCountLabel(timeline.classCount), + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.86f), + ) + } + + Column( + modifier = Modifier.padding(horizontal = AppSpace.sm, vertical = AppSpace.md), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + timeline.periods.forEach { period -> PeriodRow(period) } + } + } + } +} + +@Composable +private fun NoClassesCard() { + GradientCard( + colors = listOf(Color(0xFF22B44E), Color(0xFF00A99D)), + ) { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.md)) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.82f), + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(AppSpace.xs)) + Text( + "No Classes", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + "Enjoy your free time!", + style = MaterialTheme.typography.titleMedium, + color = Color.White.copy(alpha = 0.88f), + ) + } + } +} + +@Composable +private fun PeriodRow(item: TimelinePeriod) { + val entry = item.entry + val accent = subjectColor(entry.subject) + val past = item.phase == PeriodPhase.PAST + val current = item.phase == PeriodPhase.CURRENT + val primaryText = if (past) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.34f) + } else { + MaterialTheme.colorScheme.onSurface + } + val secondaryText = if (past) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.32f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val rowBackground = if (current) accent.copy(alpha = 0.11f) else Color.Transparent + val timeFormatter = DateTimeFormatter.ofPattern("H:mm") + val detail = buildList { + add("${item.period.start.format(timeFormatter)} - ${item.period.end.format(timeFormatter)}") + entry.teacher?.takeIf(String::isNotBlank)?.let(::add) + entry.room?.takeIf(String::isNotBlank)?.let(::add) + }.joinToString(" | ") + + Row( + modifier = Modifier + .fillMaxWidth() + .background(rowBackground, RoundedCornerShape(AppRadius.lg)) + .padding(horizontal = AppSpace.md, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(AppSpace.sm), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .padding(top = 6.dp) + .size(9.dp) + .background( + if (past) accent.copy(alpha = 0.32f) else accent, + CircleShape, + ), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + entry.subject, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (current) FontWeight.Bold else FontWeight.Medium, + color = primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + detail, + style = MaterialTheme.typography.bodyMedium, + color = secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (current) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + horizontalArrangement = Arrangement.spacedBy(AppSpace.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + LinearProgressIndicator( + progress = { item.progress }, + modifier = Modifier + .weight(1f) + .height(6.dp), + color = accent, + trackColor = accent.copy(alpha = 0.16f), + ) + Text( + "ends in ${formatRemaining(item.remainingSeconds)}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = accent, + ) + } + } + } + } +} + +@Composable +private fun QuickLinks( + onClasses: () -> Unit, + onActivities: () -> Unit, + onGrades: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + Text("Quick links", style = MaterialTheme.typography.titleLarge) + Row(horizontalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + QuickLink("Classes", Icons.Default.CalendarMonth, onClasses, Modifier.weight(1f)) + QuickLink("Activities", Icons.Default.Checklist, onActivities, Modifier.weight(1f)) + QuickLink("Grades", Icons.Default.School, onGrades, Modifier.weight(1f)) + } + } +} + +@Composable +private fun QuickLink( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(AppRadius.lg)) + .clickable(onClick = onClick) + .padding(AppSpace.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpace.xs), + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1) + } +} + +private fun greeting(hour: Int): String = when (hour) { + in 5..11 -> "Good Morning" + in 12..17 -> "Good Afternoon" + else -> "Good Evening" +} + +private fun classCountLabel(count: Int): String = "$count ${if (count == 1) "class" else "classes"}" + +private fun formatRemaining(seconds: Long): String = String.format( + Locale.ENGLISH, + "%d:%02d", + seconds / 60, + seconds % 60, +) + +private fun subjectColor(subject: String): Color { + val value = subject.lowercase() + return when { + "math" in value -> Color(0xFF3698F5) + "english" in value -> Color(0xFF7D57C2) + "chem" in value -> Color(0xFFFF4F78) + "physics" in value -> Color(0xFFFF9A45) + "econom" in value -> Color(0xFFBD9172) + "chinese" in value -> Color(0xFFC74B50) + "tok" in value -> Color(0xFF31BFD6) + "self" in value -> Color(0xFF7A7A86) + else -> Color(0xFF5E6F64) + } +} diff --git a/app/src/main/java/dev/outspire/android/feature/today/TodayViewModel.kt b/app/src/main/java/dev/outspire/android/feature/today/TodayViewModel.kt new file mode 100644 index 0000000..9181358 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/feature/today/TodayViewModel.kt @@ -0,0 +1,54 @@ +package dev.outspire.android.feature.today + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.SchoolTime +import dev.outspire.android.data.repository.OutspireRepository +import java.time.LocalDateTime +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +data class TodayUiState( + val isLoading: Boolean = false, + val schedule: List = emptyList(), + val now: LocalDateTime = SchoolTime.now(), + val error: String? = null, +) + +class TodayViewModel( + private val repository: OutspireRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(TodayUiState()) + val state: StateFlow = mutableState.asStateFlow() + + init { + viewModelScope.launch { + while (isActive) { + mutableState.update { it.copy(now = SchoolTime.now()) } + delay(1_000) + } + } + } + + fun load(forceRefresh: Boolean = false) { + if (mutableState.value.isLoading) return + viewModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + repository.loadSchedule(forceRefresh) + .onSuccess { schedule -> + mutableState.update { it.copy(isLoading = false, schedule = schedule) } + } + .onFailure { failure -> + mutableState.update { + it.copy(isLoading = false, error = failure.message ?: "Unable to load timetable.") + } + } + } + } +} diff --git a/app/src/main/java/dev/outspire/android/navigation/OutspireRoot.kt b/app/src/main/java/dev/outspire/android/navigation/OutspireRoot.kt new file mode 100644 index 0000000..2e57915 --- /dev/null +++ b/app/src/main/java/dev/outspire/android/navigation/OutspireRoot.kt @@ -0,0 +1,190 @@ +package dev.outspire.android.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.outspire.android.data.repository.OutspireRepository +import dev.outspire.android.designsystem.OutspireBackground +import dev.outspire.android.feature.ViewModelFactory +import dev.outspire.android.feature.account.AccountScreen +import dev.outspire.android.feature.account.AccountViewModel +import dev.outspire.android.feature.academic.AcademicScreen +import dev.outspire.android.feature.academic.AcademicViewModel +import dev.outspire.android.feature.academic.ScoresScreen +import dev.outspire.android.feature.activities.ActivitiesScreen +import dev.outspire.android.feature.activities.ActivitiesViewModel +import dev.outspire.android.feature.explore.ExploreScreen +import dev.outspire.android.feature.explore.PlaceholderScreen +import dev.outspire.android.feature.today.TodayScreen +import dev.outspire.android.feature.today.TodayViewModel + +enum class AppTab(val label: String, val icon: ImageVector) { + TODAY("Today", Icons.Default.Today), + CLASSES("Class", Icons.Default.CalendarMonth), + ACTIVITIES("Activities", Icons.Default.Checklist), + EXPLORE("Explore", Icons.Default.GridView), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutspireRoot( + repository: OutspireRepository, + modifier: Modifier = Modifier, +) { + val accountViewModel: AccountViewModel = viewModel( + factory = ViewModelFactory { AccountViewModel(repository) }, + ) + val todayViewModel: TodayViewModel = viewModel( + factory = ViewModelFactory { TodayViewModel(repository) }, + ) + val academicViewModel: AcademicViewModel = viewModel( + factory = ViewModelFactory { AcademicViewModel(repository) }, + ) + val activitiesViewModel: ActivitiesViewModel = viewModel( + factory = ViewModelFactory { ActivitiesViewModel(repository) }, + ) + + val user by accountViewModel.session.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val todayState by todayViewModel.state.collectAsStateWithLifecycle() + val academicState by academicViewModel.state.collectAsStateWithLifecycle() + val activitiesState by activitiesViewModel.state.collectAsStateWithLifecycle() + + var selectedTab by rememberSaveable { mutableStateOf(AppTab.TODAY) } + var detail by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(user?.code, user?.isDemo) { + if (user != null) { + todayViewModel.load() + academicViewModel.load() + activitiesViewModel.load() + } + } + + BackHandler(enabled = detail != null) { detail = null } + + OutspireBackground(modifier) { + Scaffold( + containerColor = Color.Transparent, + topBar = { + if (detail != null) { + TopAppBar( + title = { Text(detailTitle(detail.orEmpty())) }, + navigationIcon = { + IconButton(onClick = { detail = null }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + } + }, + bottomBar = { + if (detail == null) { + NavigationBar(containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surface.copy(alpha = 0.94f)) { + AppTab.entries.forEach { tab -> + NavigationBarItem( + selected = tab == selectedTab, + onClick = { selectedTab = tab }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(tab.label) }, + ) + } + } + } + }, + ) { innerPadding -> + val contentModifier = Modifier.padding(innerPadding) + when (val route = detail) { + "account" -> AccountScreen( + state = accountState, + user = user, + onCodeChange = accountViewModel::setCode, + onPasswordChange = accountViewModel::setPassword, + onLogin = { accountViewModel.login { detail = null } }, + onDemo = { accountViewModel.enterDemo { detail = null } }, + onLogout = { accountViewModel.logout {} }, + modifier = contentModifier, + ) + "scores" -> ScoresScreen( + state = academicState, + user = user, + onSignIn = { detail = "account" }, + modifier = contentModifier, + ) + null -> when (selectedTab) { + AppTab.TODAY -> TodayScreen( + state = todayState, + user = user, + onSignIn = { detail = "account" }, + onRefresh = { + todayViewModel.load(forceRefresh = true) + academicViewModel.load(forceRefresh = true) + }, + onClasses = { selectedTab = AppTab.CLASSES }, + onActivities = { selectedTab = AppTab.ACTIVITIES }, + onGrades = { detail = "scores" }, + modifier = contentModifier, + ) + AppTab.CLASSES -> AcademicScreen( + state = academicState, + user = user, + onSignIn = { detail = "account" }, + onRefresh = { academicViewModel.load(forceRefresh = true) }, + onSelectDay = academicViewModel::selectDay, + modifier = contentModifier, + ) + AppTab.ACTIVITIES -> ActivitiesScreen( + state = activitiesState, + user = user, + onSignIn = { detail = "account" }, + modifier = contentModifier, + ) + AppTab.EXPLORE -> ExploreScreen( + user = user, + onToday = { selectedTab = AppTab.TODAY }, + onClasses = { selectedTab = AppTab.CLASSES }, + onActivities = { selectedTab = AppTab.ACTIVITIES }, + onGrades = { detail = "scores" }, + onAccount = { detail = "account" }, + onPlaceholder = { detail = "placeholder:$it" }, + modifier = contentModifier, + ) + } + else -> PlaceholderScreen( + title = route.removePrefix("placeholder:"), + modifier = contentModifier, + ) + } + } + } +} + +private fun detailTitle(route: String): String = when (route) { + "account" -> "Account" + "scores" -> "Grades" + else -> route.removePrefix("placeholder:") +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..7adb7c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..18f02bb --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #0F0F17 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..dd262f1 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #F8F7FC + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23332f4..377095f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,3 @@ - Outspire diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 399de9e..b8fd738 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,16 @@ - - + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 1c1a6b7..0000000 --- a/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - 101.227.232.33 - - diff --git a/app/src/test/java/com/computerization/outspire/data/remote/ApiEnvelopeTest.kt b/app/src/test/java/com/computerization/outspire/data/remote/ApiEnvelopeTest.kt deleted file mode 100644 index 2000b1a..0000000 --- a/app/src/test/java/com/computerization/outspire/data/remote/ApiEnvelopeTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.computerization.outspire.data.remote - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class ApiEnvelopeTest { - - @Serializable - private data class Payload(val foo: String) - - private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true } - - @Test - fun `decodes success with int ResultType`() { - val envelope = json.decodeFromString>( - """{"ResultType":0,"Message":null,"Data":{"foo":"bar"}}""" - ) - assertTrue(envelope.isSuccess) - assertEquals("bar", envelope.Data?.foo) - } - - @Test - fun `decodes success with string ResultType`() { - val envelope = json.decodeFromString>( - """{"ResultType":"0","Data":{"foo":"baz"}}""" - ) - assertTrue(envelope.isSuccess) - assertEquals("baz", envelope.Data?.foo) - } - - @Test - fun `decodes failure with string ResultType`() { - val envelope = json.decodeFromString>( - """{"ResultType":"1","Message":"nope","Data":null}""" - ) - assertFalse(envelope.isSuccess) - assertEquals("nope", envelope.Message) - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/remote/TsimsClientValidatorTest.kt b/app/src/test/java/com/computerization/outspire/data/remote/TsimsClientValidatorTest.kt deleted file mode 100644 index 450d267..0000000 --- a/app/src/test/java/com/computerization/outspire/data/remote/TsimsClientValidatorTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.computerization.outspire.data.remote - -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.plugins.HttpResponseValidator -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class TsimsClientValidatorTest { - - private fun clientFor(engine: MockEngine): HttpClient = HttpClient(engine) { - expectSuccess = false - followRedirects = false - HttpResponseValidator { - validateResponse { resp -> - val s = resp.status - if (s == HttpStatusCode.Found || s == HttpStatusCode.Unauthorized) { - throw UnauthorizedException(s.value) - } - } - } - } - - @Test - fun `302 response throws UnauthorizedException`() = runTest { - val engine = MockEngine { respond("", HttpStatusCode.Found, headersOf("Location", "/Home/Login")) } - val client = clientFor(engine) - val err = runCatching { client.get("http://example/test") }.exceptionOrNull() - assertTrue(err is UnauthorizedException) - assertEquals(302, (err as UnauthorizedException).statusCode) - } - - @Test - fun `200 response passes through`() = runTest { - val engine = MockEngine { respond("ok", HttpStatusCode.OK) } - val client = clientFor(engine) - val resp = client.get("http://example/test") - assertEquals(HttpStatusCode.OK, resp.status) - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/remote/YearOptionParserTest.kt b/app/src/test/java/com/computerization/outspire/data/remote/YearOptionParserTest.kt deleted file mode 100644 index 7c6f8ca..0000000 --- a/app/src/test/java/com/computerization/outspire/data/remote/YearOptionParserTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.computerization.outspire.data.remote - -import org.jsoup.Jsoup -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class YearOptionParserTest { - - @Test - fun `parses YearId select`() { - val html = """ - - - - """.trimIndent() - val result = YearService.parseYearOptions(Jsoup.parse(html)) - assertEquals(3, result.size) - assertEquals("31", result[0].id) - assertEquals("2025-2026(T2)", result[0].name) - assertEquals("30", result[2].id) - } - - @Test - fun `falls back to ddlYear selector`() { - val html = """ - - """.trimIndent() - val result = YearService.parseYearOptions(Jsoup.parse(html)) - assertEquals(1, result.size) - assertEquals("99", result[0].id) - } - - @Test - fun `skips options with blank value`() { - val html = """ - - """.trimIndent() - val result = YearService.parseYearOptions(Jsoup.parse(html)) - assertEquals(listOf("31"), result.map { it.id }) - } - - @Test - fun `throws when no select found`() { - val html = "

nothing

" - assertThrows(IllegalStateException::class.java) { - YearService.parseYearOptions(Jsoup.parse(html)) - } - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/repository/CasDtoTest.kt b/app/src/test/java/com/computerization/outspire/data/repository/CasDtoTest.kt deleted file mode 100644 index 33b4dd1..0000000 --- a/app/src/test/java/com/computerization/outspire/data/repository/CasDtoTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.computerization.outspire.data.repository - -import com.computerization.outspire.data.model.LearningOutcome -import com.computerization.outspire.data.remote.ApiEnvelope -import com.computerization.outspire.data.remote.dto.EvaluationDto -import com.computerization.outspire.data.remote.dto.GroupDto -import com.computerization.outspire.data.remote.dto.PagedEnvelope -import com.computerization.outspire.data.remote.dto.RecordDto -import com.computerization.outspire.data.remote.dto.ReflectionDto -import com.computerization.outspire.data.repository.CasRepository.Companion.toDomain -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class CasDtoTest { - private val json = Json { ignoreUnknownKeys = true } - - @Test - fun `record uses Title when Theme is null and normalizes dash duration`() { - val dto = RecordDto( - id = "1", - groupId = "g", - title = "Cleanup", - date = "2026-04-01", - cDuration = "-", - aDuration = " 2.5 ", - sDuration = "", - isConfirm = 1, - ) - val domain = dto.toDomain() - assertEquals("Cleanup", domain.theme) - assertEquals("2026-04-01", domain.date) - assertEquals("0", domain.cDuration) - assertEquals("2.5", domain.aDuration) - assertEquals("0", domain.sDuration) - assertTrue(domain.confirmed) - } - - @Test - fun `record prefers Theme over Title and ActivityDateStr over Date`() { - val dto = RecordDto( - id = "2", groupId = "g", - theme = "Workshop", title = "ignored", - activityDateStr = "April 2", date = "ignored", - cDuration = "1", aDuration = "1", sDuration = "1", - isConfirm = 0, - ) - val domain = dto.toDomain() - assertEquals("Workshop", domain.theme) - assertEquals("April 2", domain.date) - assertFalse(domain.confirmed) - } - - @Test - fun `reflection maps outcome int to enum and strips html`() { - val dto = ReflectionDto( - id = "1", groupId = "g", - title = "Week 1", summary = "good", - content = "

Hello world

", - outcome = JsonPrimitive(3), - ) - val d = dto.toDomain() - assertEquals(LearningOutcome.INITIATIVE, d.outcome) - assertEquals("Hello world", d.contentPreview) - } - - @Test - fun `reflection with null outcome yields null enum`() { - assertNull(ReflectionDto(outcome = null).toDomain().outcome) - assertNull(ReflectionDto(outcome = JsonPrimitive(99)).toDomain().outcome) - } - - @Test - fun `reflection accepts comma separated outcome string`() { - val dto = ReflectionDto( - id = "1", - groupId = "g", - outcome = JsonPrimitive("1,3"), - outcomeIdList = listOf(1, 3), - ) - assertEquals(LearningOutcome.AWARENESS, dto.toDomain().outcome) - } - - @Test - fun `paged envelope decodes from json`() { - val raw = """ - { - "ResultType": 0, - "Data": { - "PageIndex": 1, - "PageSize": 20, - "TotalCount": 101, - "PageCount": 6, - "List": [ - {"Id":"31","NameC":"机器人","TeacherName":"Mr Wang"}, - {"Id":"32","NameE":"Robotics","TeacherName":"Ms Li"} - ] - } - } - """.trimIndent() - val env = json.decodeFromString( - ApiEnvelope.serializer(PagedEnvelope.serializer(GroupDto.serializer())), - raw, - ) - assertTrue(env.isSuccess) - val page = env.Data!! - assertEquals(101, page.TotalCount) - assertEquals(6, page.PageCount) - assertEquals(2, page.List.size) - assertEquals("机器人", page.List[0].toDomain().name) - assertEquals("Robotics", page.List[1].toDomain().name) - } - - @Test - fun `my groups envelope decodes plain list`() { - val raw = """ - {"ResultType":0,"Data":[{"Id":"7","NameC":"演讲","TeacherName":"X"}]} - """.trimIndent() - val env = json.decodeFromString( - ApiEnvelope.serializer(ListSerializer(GroupDto.serializer())), - raw, - ) - val groups = env.Data!!.map { it.toDomain() } - assertEquals(1, groups.size) - assertEquals("演讲", groups[0].name) - } - - @Test - fun `evaluation dto decodes and maps`() { - val raw = """ - { - "ResultType": 0, - "Data": { - "GroupRecordList": [ - {"GroupName":"Robotics","CDuration":"5","ADuration":"3","SDuration":"2"} - ], - "RecLevel":"A", - "RefLevel":"B", - "Talk":"4", - "Final":"4.5" - } - } - """.trimIndent() - val env = json.decodeFromString(ApiEnvelope.serializer(EvaluationDto.serializer()), raw) - val d = env.Data!!.toDomain() - assertEquals("A", d.recLevel) - assertEquals("4.5", d.finalScore) - assertEquals(1, d.groups.size) - assertEquals("Robotics", d.groups[0].name) - assertEquals("5", d.groups[0].cDuration) - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/repository/ScoreRepositoryTest.kt b/app/src/test/java/com/computerization/outspire/data/repository/ScoreRepositoryTest.kt deleted file mode 100644 index 75e86d9..0000000 --- a/app/src/test/java/com/computerization/outspire/data/repository/ScoreRepositoryTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.computerization.outspire.data.repository - -import com.computerization.outspire.data.remote.ApiEnvelope -import com.computerization.outspire.data.remote.dto.ScoreItemDto -import com.computerization.outspire.data.repository.ScoreRepository.Companion.toDomain -import kotlinx.serialization.json.Json -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class ScoreRepositoryTest { - - private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true } - - @Test - fun `envelope with dash normalizes raw to zero and ib to blank`() { - val payload = """ - {"ResultType":0,"Data":[ - {"SubjectId":1,"SubjectName":"Maths AA HL", - "Score1":"70","IbScore1":"7", - "Score2":"-","IbScore2":"-", - "Score3":" 82 ","IbScore3":"6"} - ]} - """.trimIndent() - val envelope = json.decodeFromString>>(payload) - assertTrue(envelope.isSuccess) - val domain = envelope.Data!!.first().toDomain() - assertEquals("Maths AA HL", domain.subject) - assertEquals(3, domain.terms.size) - assertEquals("70", domain.terms[0].raw) - assertEquals("7", domain.terms[0].ib) - assertEquals("0", domain.terms[1].raw) - assertEquals("", domain.terms[1].ib) - assertEquals("82", domain.terms[2].raw) - } - - @Test - fun `all empty trailing terms are dropped`() { - val dto = ScoreItemDto( - subjectName = "English", - score1 = "80", ibScore1 = "6", - score2 = null, ibScore2 = null, - score3 = "-", ibScore3 = "-", - ) - val domain = dto.toDomain() - assertEquals(1, domain.terms.size) - assertEquals("T1", domain.terms[0].label) - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/repository/TimetableMapperTest.kt b/app/src/test/java/com/computerization/outspire/data/repository/TimetableMapperTest.kt deleted file mode 100644 index c63569a..0000000 --- a/app/src/test/java/com/computerization/outspire/data/repository/TimetableMapperTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.computerization.outspire.data.repository - -import com.computerization.outspire.data.remote.dto.LessonSlot -import com.computerization.outspire.data.remote.dto.PeriodRow -import com.computerization.outspire.data.remote.dto.TimetableDto -import kotlinx.datetime.LocalTime -import org.junit.Assert.assertEquals -import org.junit.Test - -class TimetableMapperTest { - - private fun slot(subject: String, lesson: Int) = - LessonSlot(subject = subject, teacher = "T", room = "R", lessonNumber = lesson, weekNumber = 1) - - private val dto = TimetableDto( - weekList = emptyList(), - periods = listOf( - PeriodRow("1", listOf(slot("Math", 1), null, slot("Eng", 1), null, null)), - PeriodRow("2", listOf(null, slot("Phys", 2), null, null, null)), - PeriodRow("3", listOf(slot("Chem", 3), slot("Hist", 3), null, null, null)), - ), - ) - - @Test - fun `day index 0 picks only monday slots in period order with times`() { - val out = TimetableRepository.mapDay(dto, 0) - assertEquals(listOf("Math", "Chem"), out.map { it.subject }) - assertEquals(LocalTime(8, 15), out[0].start) - assertEquals(LocalTime(8, 55), out[0].end) - assertEquals(LocalTime(9, 55), out[1].start) - } - - @Test - fun `day index 2 picks wednesday slots`() { - val out = TimetableRepository.mapDay(dto, 2) - assertEquals(listOf("Eng"), out.map { it.subject }) - } - - @Test - fun `empty day returns empty list`() { - val out = TimetableRepository.mapDay(dto, 3) - assertEquals(emptyList(), out.map { it.subject }) - } -} diff --git a/app/src/test/java/com/computerization/outspire/data/repository/TimetableWeekMapperTest.kt b/app/src/test/java/com/computerization/outspire/data/repository/TimetableWeekMapperTest.kt deleted file mode 100644 index 463d008..0000000 --- a/app/src/test/java/com/computerization/outspire/data/repository/TimetableWeekMapperTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.computerization.outspire.data.repository - -import com.computerization.outspire.data.remote.dto.LessonSlot -import com.computerization.outspire.data.remote.dto.PeriodRow -import com.computerization.outspire.data.remote.dto.TimetableDto -import org.junit.Assert.assertEquals -import org.junit.Test - -class TimetableWeekMapperTest { - - private fun slot(subject: String, lesson: Int) = - LessonSlot(subject = subject, teacher = "T", room = "R", lessonNumber = lesson, weekNumber = 1) - - private val dto = TimetableDto( - periods = listOf( - PeriodRow("1", listOf(slot("Math", 1), null, slot("Eng", 1), null, slot("PE", 1))), - PeriodRow("2", listOf(null, slot("Phys", 2), null, null, null)), - ), - ) - - @Test - fun `all five weekday indices populate expected slots`() { - val grid = (0..4).associateWith { TimetableRepository.mapDay(dto, it) } - assertEquals(listOf("Math"), grid[0]!!.map { it.subject }) - assertEquals(listOf("Phys"), grid[1]!!.map { it.subject }) - assertEquals(listOf("Eng"), grid[2]!!.map { it.subject }) - assertEquals(emptyList(), grid[3]!!.map { it.subject }) - assertEquals(listOf("PE"), grid[4]!!.map { it.subject }) - } -} diff --git a/app/src/test/java/com/computerization/outspire/feature/today/TodayViewModelTest.kt b/app/src/test/java/com/computerization/outspire/feature/today/TodayViewModelTest.kt deleted file mode 100644 index 00676da..0000000 --- a/app/src/test/java/com/computerization/outspire/feature/today/TodayViewModelTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.computerization.outspire.feature.today - -import com.computerization.outspire.data.model.DomainClass -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TodayViewModelTest { - - private val classes = listOf( - DomainClass("English", "Ms. C", "A1", LocalTime(9, 0), LocalTime(9, 45)), - DomainClass("Math", "Mr. L", "B1", LocalTime(10, 0), LocalTime(10, 45)), - ) - - private fun at(h: Int, m: Int) = LocalDateTime( - LocalDate(2026, 4, 13), - LocalTime(h, m), - ).toInstant(TimeZone.currentSystemDefault()) - - @Test - fun `in class returns Weekday with active index`() { - val state = TodayViewModel.computeState(at(9, 30), classes, dayOfWeek = DayOfWeek.MONDAY) - assertTrue(state is TodayUiState.Weekday) - state as TodayUiState.Weekday - assertEquals(0, state.activeIndex) - } - - @Test - fun `break returns Weekday with null active index`() { - val state = TodayViewModel.computeState(at(9, 50), classes, dayOfWeek = DayOfWeek.MONDAY) - assertTrue(state is TodayUiState.Weekday) - assertNull((state as TodayUiState.Weekday).activeIndex) - } - - @Test - fun `after last class is DayDone afterSchool`() { - val state = TodayViewModel.computeState(at(23, 0), classes, dayOfWeek = DayOfWeek.MONDAY) - assertTrue(state is TodayUiState.DayDone) - state as TodayUiState.DayDone - assertTrue(state.isAfterSchool) - assertTrue(!state.isWeekend) - } - - @Test - fun `before first class is Weekday with null active index`() { - val state = TodayViewModel.computeState(at(7, 0), classes, dayOfWeek = DayOfWeek.MONDAY) - assertTrue(state is TodayUiState.Weekday) - assertNull((state as TodayUiState.Weekday).activeIndex) - } - - @Test - fun `weekend short-circuits to DayDone`() { - val state = TodayViewModel.computeState( - at(10, 0), classes, isWeekend = true, dayOfWeek = DayOfWeek.SATURDAY, - ) - assertTrue(state is TodayUiState.DayDone) - assertTrue((state as TodayUiState.DayDone).isWeekend) - } -} diff --git a/app/src/test/java/dev/outspire/android/data/model/CasActivityTest.kt b/app/src/test/java/dev/outspire/android/data/model/CasActivityTest.kt new file mode 100644 index 0000000..7f0449d --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/model/CasActivityTest.kt @@ -0,0 +1,27 @@ +package dev.outspire.android.data.model + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CasActivityTest { + @Test + fun derivesTotalsCategoriesAndReflectionState() { + val activity = CasActivity( + id = "1", + title = "Campus event", + club = "Computerization", + date = LocalDate.of(2026, 7, 10), + creativityHours = 1.5, + serviceHours = 2.0, + reflection = "I learned how to coordinate the support team.", + ) + + assertEquals(3.5, activity.hours, 0.001) + assertEquals(setOf(CasCategory.CREATIVITY, CasCategory.SERVICE), activity.categories) + assertTrue(activity.reflectionComplete) + assertFalse(CasCategory.ACTIVITY in activity.categories) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/model/ClassInfoParserTest.kt b/app/src/test/java/dev/outspire/android/data/model/ClassInfoParserTest.kt new file mode 100644 index 0000000..fb49d7e --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/model/ClassInfoParserTest.kt @@ -0,0 +1,29 @@ +package dev.outspire.android.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClassInfoParserTest { + @Test + fun parsesTeacherSubjectAndRoom() { + val result = ClassInfoParser.parse("Ms Chen
Mathematics AA HL
A401") + + assertEquals("Ms Chen", result.teacher) + assertEquals("Mathematics AA HL", result.subject) + assertEquals("A401", result.room) + assertFalse(result.isSelfStudy) + } + + @Test + fun emptyCellBecomesSelfStudy() { + val result = ClassInfoParser.parse(" ") + + assertEquals("Self-Study", result.subject) + assertTrue(result.isSelfStudy) + assertNull(result.teacher) + assertNull(result.room) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/model/ScheduleResolverTest.kt b/app/src/test/java/dev/outspire/android/data/model/ScheduleResolverTest.kt new file mode 100644 index 0000000..660aaa3 --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/model/ScheduleResolverTest.kt @@ -0,0 +1,117 @@ +package dev.outspire.android.data.model + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScheduleResolverTest { + private val monday = LocalDate.of(2026, 7, 6) + private val schedule = listOf( + ScheduleEntry(DayOfWeek.MONDAY, 1, "Mathematics"), + ScheduleEntry(DayOfWeek.MONDAY, 3, "Physics"), + ) + + @Test + fun resolvesActivePeriod() { + val result = ScheduleResolver.currentOrNext( + schedule = schedule, + now = LocalDateTime.of(monday, LocalTime.of(8, 35)), + ) + + requireNotNull(result) + assertEquals(1, result.period.number) + assertEquals("Mathematics", result.entry.subject) + assertTrue(result.active) + assertEquals(0.5f, result.progress, 0.02f) + } + + @Test + fun skipsEmptyFuturePeriods() { + val result = ScheduleResolver.currentOrNext( + schedule = schedule, + now = LocalDateTime.of(monday, LocalTime.of(9, 0)), + ) + + requireNotNull(result) + assertEquals(3, result.period.number) + assertFalse(result.active) + } + + @Test + fun returnsNullOnWeekend() { + val result = ScheduleResolver.currentOrNext( + schedule = schedule, + now = LocalDateTime.of(2026, 7, 11, 9, 0), + ) + + assertNull(result) + } + + @Test + fun fillsMissingPeriodsWithSelfStudy() { + val result = ScheduleResolver.daySchedule(schedule, DayOfWeek.MONDAY) + + assertEquals(9, result.size) + assertEquals("Self-Study", result[1].subject) + assertTrue(result[1].isSelfStudy) + } + + @Test + fun buildsTodayTimelineWithPastCurrentAndUpcomingPeriods() { + val timeline = ScheduleResolver.todayTimeline( + schedule = listOf( + ScheduleEntry(DayOfWeek.MONDAY, 1, "English"), + ScheduleEntry(DayOfWeek.MONDAY, 2, "Self-Study"), + ScheduleEntry(DayOfWeek.MONDAY, 3, "Mathematics"), + ), + now = LocalDateTime.of(monday, LocalTime.of(10, 16)), + ) + + assertTrue(timeline.hasRemainingClasses) + assertEquals(2, timeline.classCount) + assertEquals(PeriodPhase.PAST, timeline.periods[0].phase) + assertEquals(PeriodPhase.PAST, timeline.periods[1].phase) + assertEquals(PeriodPhase.CURRENT, timeline.periods[2].phase) + assertEquals(PeriodPhase.UPCOMING, timeline.periods[3].phase) + assertEquals(0.525f, timeline.periods[2].progress, 0.001f) + assertEquals(19 * 60L, timeline.periods[2].remainingSeconds) + } + + @Test + fun hidesScheduleAtTheEndOfTheNinthPeriod() { + val timeline = ScheduleResolver.todayTimeline( + schedule = schedule, + now = LocalDateTime.of(monday, LocalTime.of(16, 30)), + ) + + assertFalse(timeline.hasRemainingClasses) + } + + @Test + fun hidesScheduleWhenTheDayOnlyContainsSelfStudy() { + val timeline = ScheduleResolver.todayTimeline( + schedule = listOf(ScheduleEntry(DayOfWeek.MONDAY, 1, "Self Study")), + now = LocalDateTime.of(monday, LocalTime.of(8, 30)), + ) + + assertEquals(0, timeline.classCount) + assertFalse(timeline.hasRemainingClasses) + } + + @Test + fun hidesScheduleOnWeekend() { + val timeline = ScheduleResolver.todayTimeline( + schedule = schedule, + now = LocalDateTime.of(2026, 7, 11, 9, 0), + ) + + assertTrue(timeline.periods.isEmpty()) + assertFalse(timeline.hasRemainingClasses) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/model/ScheduleSettingsTest.kt b/app/src/test/java/dev/outspire/android/data/model/ScheduleSettingsTest.kt new file mode 100644 index 0000000..b0ec880 --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/model/ScheduleSettingsTest.kt @@ -0,0 +1,29 @@ +package dev.outspire.android.data.model + +import java.time.LocalDate +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScheduleSettingsTest { + private val today = LocalDate.of(2026, 7, 11) + + @Test + fun holidayWithoutEndDateStaysActive() { + val settings = ScheduleSettings(holidayEnabled = true, holidayEndDateEnabled = false) + + assertTrue(settings.isHolidayActive(today.plusYears(1))) + } + + @Test + fun datedHolidayExpiresAfterItsEndDate() { + val settings = ScheduleSettings( + holidayEnabled = true, + holidayEndDateEnabled = true, + holidayEndDate = today.plusDays(2), + ) + + assertTrue(settings.isHolidayActive(today.plusDays(2))) + assertFalse(settings.isHolidayActive(today.plusDays(3))) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/remote/TsimsCasDateParserTest.kt b/app/src/test/java/dev/outspire/android/data/remote/TsimsCasDateParserTest.kt new file mode 100644 index 0000000..f55da3d --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/remote/TsimsCasDateParserTest.kt @@ -0,0 +1,21 @@ +package dev.outspire.android.data.remote + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TsimsCasDateParserTest { + @Test + fun parsesSupportedTsimsDates() { + assertEquals(LocalDate.of(2026, 7, 10), parseCasDate("2026-07-10 09:30:00")) + assertEquals(LocalDate.of(2026, 7, 10), parseCasDate("2026/7/10")) + assertEquals(LocalDate.of(2026, 7, 10), parseCasDate("7/10/2026")) + } + + @Test + fun returnsNullForMissingOrInvalidDates() { + assertNull(parseCasDate("")) + assertNull(parseCasDate("not a date")) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/remote/TsimsProfileParserTest.kt b/app/src/test/java/dev/outspire/android/data/remote/TsimsProfileParserTest.kt new file mode 100644 index 0000000..eb31453 --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/remote/TsimsProfileParserTest.kt @@ -0,0 +1,57 @@ +package dev.outspire.android.data.remote + +import dev.outspire.android.data.model.User +import org.junit.Assert.assertEquals +import org.junit.Test + +class TsimsProfileParserTest { + private val fallback = User( + id = null, + code = "fallback-code", + name = "Fallback User", + role = "Student", + ) + + @Test + fun `parses identity fields from profile inputs`() { + val html = """ +
+ + + + +
+ """.trimIndent() + + val user = TsimsProfileParser.parse(html, fallback) + + assertEquals(42, user.id) + assertEquals("s20238123", user.code) + assertEquals("Ada Lovelace", user.name) + assertEquals("Student", user.role) + } + + @Test + fun `falls back to adjacent table cells and decodes common entities`() { + val studentCodeLabel = "\u5b66\u53f7" + val studentNameLabel = "\u59d3\u540d" + val html = """ + + + +
$studentCodeLabel20239999
$studentNameLabelLin & Kai
+ """.trimIndent() + + val user = TsimsProfileParser.parse(html, fallback) + + assertEquals("20239999", user.code) + assertEquals("Lin & Kai", user.name) + } + + @Test + fun `keeps login response values when profile fields are absent`() { + val user = TsimsProfileParser.parse("No profile form", fallback) + + assertEquals(fallback, user) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/remote/TsimsRedirectPolicyTest.kt b/app/src/test/java/dev/outspire/android/data/remote/TsimsRedirectPolicyTest.kt new file mode 100644 index 0000000..eb5ddc6 --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/remote/TsimsRedirectPolicyTest.kt @@ -0,0 +1,32 @@ +package dev.outspire.android.data.remote + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TsimsRedirectPolicyTest { + @Test + fun `credential post may legitimately finish at login endpoint`() { + assertFalse(isUnexpectedLoginRedirect("/Home/Login", "/Home/Login")) + } + + @Test + fun `redirect from authenticated endpoint back to login is rejected`() { + assertTrue( + isUnexpectedLoginRedirect( + "/Stu/Timetable/GetTimetableByStudent", + "/Home/Login", + ), + ) + } + + @Test + fun `login path comparison tolerates case trailing slash and query`() { + assertFalse( + isUnexpectedLoginRedirect( + "/home/login?ReturnUrl=%2F", + "/Home/Login/", + ), + ) + } +} diff --git a/app/src/test/java/dev/outspire/android/data/repository/DefaultOutspireRepositoryTest.kt b/app/src/test/java/dev/outspire/android/data/repository/DefaultOutspireRepositoryTest.kt new file mode 100644 index 0000000..24be68e --- /dev/null +++ b/app/src/test/java/dev/outspire/android/data/repository/DefaultOutspireRepositoryTest.kt @@ -0,0 +1,96 @@ +package dev.outspire.android.data.repository + +import dev.outspire.android.data.model.ScheduleEntry +import dev.outspire.android.data.model.User +import dev.outspire.android.data.remote.TsimsDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultOutspireRepositoryTest { + private val signedInUser = User( + id = 7, + code = "s20238123", + name = "Outspire Student", + role = "Student", + ) + + @Test + fun `login validates both fields before contacting TSIMS`() = runTest { + val client = FakeTsimsDataSource(Result.success(signedInUser)) + val repository = DefaultOutspireRepository(client) + + val result = repository.login(" ", "password") + + assertTrue(result.isFailure) + assertEquals("Enter both your student code and password.", result.exceptionOrNull()?.message) + assertEquals(0, client.loginCalls) + assertNull(repository.session.value) + } + + @Test + fun `successful login trims code and publishes session`() = runTest { + val client = FakeTsimsDataSource(Result.success(signedInUser)) + val repository = DefaultOutspireRepository(client) + + val result = repository.login(" s20238123 ", "secret password") + + assertSame(signedInUser, result.getOrThrow()) + assertEquals("s20238123", client.lastCode) + assertEquals("secret password", client.lastPassword) + assertSame(signedInUser, repository.session.value) + } + + @Test + fun `failed login leaves repository signed out`() = runTest { + val failure = IllegalStateException("Invalid code or password") + val client = FakeTsimsDataSource(Result.failure(failure)) + val repository = DefaultOutspireRepository(client) + + val result = repository.login("s20238123", "wrong") + + assertSame(failure, result.exceptionOrNull()) + assertNull(repository.session.value) + } + + @Test + fun `logout clears published session and delegates server logout`() = runTest { + val client = FakeTsimsDataSource(Result.success(signedInUser)) + val repository = DefaultOutspireRepository(client) + repository.login("s20238123", "secret") + + repository.logout() + + assertEquals(1, client.logoutCalls) + assertNull(repository.session.value) + } + + private class FakeTsimsDataSource( + private val loginResult: Result, + ) : TsimsDataSource { + override val isConfigured = true + var loginCalls = 0 + var logoutCalls = 0 + var lastCode: String? = null + var lastPassword: String? = null + + override suspend fun login(code: String, password: String): Result { + loginCalls += 1 + lastCode = code + lastPassword = password + return loginResult + } + + override suspend fun logout() { + logoutCalls += 1 + } + + override fun clearSession() = Unit + + override suspend fun loadTimetable(user: User): Result> = + Result.success(emptyList()) + } +} diff --git a/app/src/test/java/dev/outspire/android/feature/academic/AcademicViewModelTest.kt b/app/src/test/java/dev/outspire/android/feature/academic/AcademicViewModelTest.kt new file mode 100644 index 0000000..3f68405 --- /dev/null +++ b/app/src/test/java/dev/outspire/android/feature/academic/AcademicViewModelTest.kt @@ -0,0 +1,22 @@ +package dev.outspire.android.feature.academic + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class AcademicViewModelTest { + @Test + fun keepsWeekdaysSelected() { + val monday = LocalDate.of(2026, 7, 6) + + assertEquals(monday, nearestSchoolDate(monday)) + } + + @Test + fun mapsWeekendDatesToFriday() { + val friday = LocalDate.of(2026, 7, 10) + + assertEquals(friday, nearestSchoolDate(LocalDate.of(2026, 7, 11))) + assertEquals(friday, nearestSchoolDate(LocalDate.of(2026, 7, 12))) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 73af60d..39f6de0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,5 @@ plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.hilt) apply false - alias(libs.plugins.ksp) apply false + id("com.android.application") version "8.13.0" apply false + id("org.jetbrains.kotlin.android") version "2.1.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false } diff --git a/gradle.properties b/gradle.properties index b89bb35..f0a2e55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -org.gradle.parallel=true -org.gradle.caching=true android.useAndroidX=true -android.nonTransitiveRClass=true kotlin.code.style=official - -foojay.discoveryservice.baseurl=https://mirrors.cloud.tencent.com/foojay/disco/v3.0 \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index 5c34300..0000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,13 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect -toolchainVendor=JETBRAINS -toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index f0f019d..0000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,65 +0,0 @@ -[versions] -agp = "8.13.2" -kotlin = "2.0.21" -ksp = "2.0.21-1.0.28" -coreKtx = "1.13.1" -lifecycle = "2.8.6" -activityCompose = "1.9.2" -composeBom = "2024.10.00" -navigationCompose = "2.8.3" -hilt = "2.51.1" -hiltNavigationCompose = "1.2.0" -coroutines = "1.9.0" -datetime = "0.6.1" -ktor = "2.3.12" -kotlinxSerialization = "1.7.3" -jsoup = "1.17.2" -securityCrypto = "1.1.0-alpha06" -datastore = "1.1.1" -junit = "4.13.2" -coroutinesTest = "1.9.0" -turbine = "1.1.0" - -[libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-compose-material = { group = "androidx.compose.material", name = "material" } -androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } -hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } -hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } -kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "datetime" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } - -ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } -ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } -ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } -ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } -ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } -ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } - -jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } -androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } -androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } - -junit = { group = "junit", name = "junit", version.ref = "junit" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } -turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } - -[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60..61285a6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0d311c1..31f0336 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Fri Jul 10 19:31:04 JST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.0.0/gradle-9.0.0-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 index ef07e01..adff685 --- a/gradlew +++ b/gradlew @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,94 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties.example b/local.properties.example new file mode 100644 index 0000000..17ed56e --- /dev/null +++ b/local.properties.example @@ -0,0 +1,4 @@ +# Copy this file to local.properties and add an HTTPS TSIMS endpoint. +# Android SDK paths also commonly belong in local.properties: +# sdk.dir=/absolute/path/to/Android/Sdk +tsims.baseUrl= diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ef3239..82dd3ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,42 +1,18 @@ pluginManagement { repositories { - maven { - url = uri("https://maven.aliyun.com/repository/google") - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - maven { url = uri("https://maven.aliyun.com/repository/central") } + google() mavenCentral() - maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } gradlePluginPortal() } } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - maven { - url = uri("https://maven.aliyun.com/repository/google") - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } google() - maven { url = uri("https://maven.aliyun.com/repository/central") } mavenCentral() } } -rootProject.name = "My Application" -include(":app") \ No newline at end of file +rootProject.name = "Outspire" +include(":app")