From de37abb9b153ea1ba8f4af2cf563e4ca13319259 Mon Sep 17 00:00:00 2001 From: EMEEEEMMMM Date: Thu, 16 Apr 2026 21:04:02 +0800 Subject: [PATCH 1/2] Fix CAS HTML rendering with WebView and refine header/detail UI --- app/build.gradle.kts | 1 + .../computerization/outspire/MainActivity.kt | 5 +- .../outspire/data/remote/CasService.kt | 33 +++- .../outspire/data/remote/dto/CasDto.kt | 4 +- .../outspire/data/repository/CasRepository.kt | 17 +- .../designsystem/OutspireBackground.kt | 77 +++++++++ .../outspire/designsystem/OutspireScreen.kt | 82 +++++++++ .../outspire/designsystem/Theme.kt | 6 + .../outspire/designsystem/Type.kt | 4 +- .../feature/academic/AcademicScreen.kt | 48 ++++-- .../feature/academic/AcademicViewModel.kt | 10 ++ .../outspire/feature/cas/CasScreen.kt | 137 +++++++++------- .../outspire/feature/cas/CasViewModel.kt | 13 ++ .../outspire/feature/cas/ClubDetailScreen.kt | 50 ++++-- .../outspire/feature/cas/HtmlText.kt | 155 ++++++++++++++++++ .../outspire/feature/cas/MyClubsTab.kt | 4 +- .../outspire/feature/login/LoginScreen.kt | 129 +++++++++------ .../feature/settings/SettingsScreen.kt | 30 ++-- .../outspire/feature/today/TodayScreen.kt | 130 +++++++++------ .../outspire/feature/today/TodayViewModel.kt | 6 + .../outspire/navigation/OutspireRoot.kt | 2 + .../outspire/data/repository/CasDtoTest.kt | 16 +- gradle.properties | 2 + gradle/libs.versions.toml | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 27 ++- 26 files changed, 766 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/com/computerization/outspire/designsystem/OutspireBackground.kt create mode 100644 app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt create mode 100644 app/src/main/java/com/computerization/outspire/feature/cas/HtmlText.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e03ed1..54245bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material.icons.extended) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/app/src/main/java/com/computerization/outspire/MainActivity.kt b/app/src/main/java/com/computerization/outspire/MainActivity.kt index ef78f10..79cb1d2 100644 --- a/app/src/main/java/com/computerization/outspire/MainActivity.kt +++ b/app/src/main/java/com/computerization/outspire/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.computerization.outspire.designsystem.OutspireBackground import com.computerization.outspire.designsystem.OutspireTheme import com.computerization.outspire.navigation.OutspireRoot import dagger.hilt.android.AndroidEntryPoint @@ -15,7 +16,9 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { OutspireTheme { - OutspireRoot() + OutspireBackground { + OutspireRoot() + } } } } diff --git a/app/src/main/java/com/computerization/outspire/data/remote/CasService.kt b/app/src/main/java/com/computerization/outspire/data/remote/CasService.kt index 9b01aee..7cccc4a 100644 --- a/app/src/main/java/com/computerization/outspire/data/remote/CasService.kt +++ b/app/src/main/java/com/computerization/outspire/data/remote/CasService.kt @@ -10,17 +10,24 @@ import io.ktor.client.call.body import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.Parameters import io.ktor.http.contentType import javax.inject.Inject import javax.inject.Singleton +import kotlinx.serialization.json.Json @Singleton class CasService @Inject constructor( private val client: HttpClient, private val authService: AuthService, ) { + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + } suspend fun getMyGroups(): List = authService.withAuthRetry { val env: ApiEnvelope> = client.post("/Stu/Cas/GetMyGroupList") { @@ -65,16 +72,19 @@ class CasService @Inject constructor( } suspend fun getReflections(groupId: String): List = authService.withAuthRetry { - val env: ApiEnvelope> = - client.post("/Stu/Cas/GetReflectionList") { - form( - mapOf( - "pageIndex" to "1", - "pageSize" to "100", - "groupId" to groupId, - ) + val raw = client.post("/Stu/Cas/GetReflectionList") { + form( + mapOf( + "pageIndex" to "1", + "pageSize" to "100", + "groupId" to groupId, ) - }.body() + ) + }.bodyAsText() + val env: ApiEnvelope> = json.decodeFromString( + ApiEnvelope.serializer(PagedEnvelope.serializer(ReflectionDto.serializer())), + sanitizeReflectionJson(raw), + ) env.require("GetReflectionList")?.List.orEmpty() } @@ -149,6 +159,11 @@ class CasService @Inject constructor( } } +private fun sanitizeReflectionJson(raw: String): String = raw + // Backend occasionally injects raw CR/LF into JSON strings (e.g. ""), which is invalid JSON. + .replace("\r", "") + .replace("\n", "") + private fun io.ktor.client.request.HttpRequestBuilder.form(fields: Map) { contentType(ContentType.Application.FormUrlEncoded) setBody(FormDataContent(Parameters.build { fields.forEach { (k, v) -> append(k, v) } })) diff --git a/app/src/main/java/com/computerization/outspire/data/remote/dto/CasDto.kt b/app/src/main/java/com/computerization/outspire/data/remote/dto/CasDto.kt index dfbebe5..996626c 100644 --- a/app/src/main/java/com/computerization/outspire/data/remote/dto/CasDto.kt +++ b/app/src/main/java/com/computerization/outspire/data/remote/dto/CasDto.kt @@ -2,6 +2,7 @@ package com.computerization.outspire.data.remote.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class GroupDto( @@ -39,7 +40,8 @@ data class ReflectionDto( @SerialName("Title") val title: String? = null, @SerialName("Summary") val summary: String? = null, @SerialName("Content") val content: String? = null, - @SerialName("Outcome") val outcome: Int? = null, + @SerialName("Outcome") val outcome: JsonElement? = null, + @SerialName("OutcomeIdList") val outcomeIdList: List = emptyList(), ) @Serializable diff --git a/app/src/main/java/com/computerization/outspire/data/repository/CasRepository.kt b/app/src/main/java/com/computerization/outspire/data/repository/CasRepository.kt index 0275b2b..28c3c29 100644 --- a/app/src/main/java/com/computerization/outspire/data/repository/CasRepository.kt +++ b/app/src/main/java/com/computerization/outspire/data/repository/CasRepository.kt @@ -13,6 +13,9 @@ import com.computerization.outspire.data.remote.dto.RecordDto import com.computerization.outspire.data.remote.dto.ReflectionDto import javax.inject.Inject import javax.inject.Singleton +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive @Singleton class CasRepository @Inject constructor( @@ -117,7 +120,9 @@ class CasRepository @Inject constructor( title = title.orEmpty().trim(), summary = summary.orEmpty().trim(), contentPreview = stripHtml(content.orEmpty()).take(200), - outcome = LearningOutcome.from(outcome), + outcome = LearningOutcome.from( + outcomeIdList.firstOrNull() ?: outcomeCode(outcome), + ), ) internal fun EvaluationDto.toDomain(): DomainEvaluation = DomainEvaluation( @@ -156,5 +161,15 @@ class CasRepository @Inject constructor( .replace(""", "\"") .replace(Regex("\\s+"), " ") .trim() + + private fun outcomeCode(value: kotlinx.serialization.json.JsonElement?): Int? { + val primitive = value?.jsonPrimitive ?: return null + return primitive.intOrNull + ?: primitive.content + .split(',') + .firstOrNull() + ?.trim() + ?.toIntOrNull() + } } } diff --git a/app/src/main/java/com/computerization/outspire/designsystem/OutspireBackground.kt b/app/src/main/java/com/computerization/outspire/designsystem/OutspireBackground.kt new file mode 100644 index 0000000..38c84d2 --- /dev/null +++ b/app/src/main/java/com/computerization/outspire/designsystem/OutspireBackground.kt @@ -0,0 +1,77 @@ +package com.computerization.outspire.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.material3.MaterialTheme + +@Composable +fun OutspireBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + val dark = isSystemInDarkTheme() + val primary = cs.primary + + Box( + modifier = modifier + .fillMaxSize() + .background(cs.background), + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawBackdrop( + primary = primary, + background = cs.background, + dark = dark, + ) + } + content() + } +} + +private fun DrawScope.drawBackdrop( + primary: Color, + background: Color, + dark: Boolean, +) { + val w = size.width + val h = size.height + + val a1 = if (dark) 0.14f else 0.10f + val a2 = if (dark) 0.10f else 0.06f + + // Two large soft blobs, biased to the top, to keep the UI "airy" without looking busy. + drawCircle( + color = primary.copy(alpha = a1), + radius = w * 0.85f, + center = Offset(w * 0.10f, -h * 0.10f), + ) + drawCircle( + color = primary.copy(alpha = a2), + radius = w * 0.95f, + center = Offset(w * 1.15f, h * 0.10f), + ) + + // Fade to the base background to avoid tinting the content area too much. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + background.copy(alpha = 0.65f), + background, + ), + startY = 0f, + endY = h, + ), + size = size, + ) +} diff --git a/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt b/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt new file mode 100644 index 0000000..bd059f8 --- /dev/null +++ b/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt @@ -0,0 +1,82 @@ +package com.computerization.outspire.designsystem + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.style.TextOverflow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutspireScreen( + title: String, + snackbarHostState: SnackbarHostState? = null, + onRefresh: (() -> Unit)? = null, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = AppSpace.md, vertical = AppSpace.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium.copy( + shadow = Shadow( + color = Color.Black.copy(alpha = 0.4f), + offset = Offset(0f, 2f), + blurRadius = 8f, + ), + ), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (onRefresh != null) { + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "Refresh", + tint = Color.White, + ) + } + } + } + }, + snackbarHost = { + if (snackbarHostState != null) SnackbarHost(snackbarHostState) + }, + content = { inner -> + Box(modifier = Modifier.fillMaxSize()) { + content(inner) + } + }, + ) +} diff --git a/app/src/main/java/com/computerization/outspire/designsystem/Theme.kt b/app/src/main/java/com/computerization/outspire/designsystem/Theme.kt index 0981ddd..ecb3e99 100644 --- a/app/src/main/java/com/computerization/outspire/designsystem/Theme.kt +++ b/app/src/main/java/com/computerization/outspire/designsystem/Theme.kt @@ -9,11 +9,17 @@ import androidx.compose.ui.graphics.Color private val LightBg = Color(0xFFF7F7FA) private val LightSurface = Color(0xFFFFFFFF) +private val LightSurfaceVariant = Color(0xFFF0F1F6) +private val LightOnSurfaceVariant = Color(0xFF2C2F3A) +private val LightOutline = Color(0xFFD0D3DD) private val LightColors = lightColorScheme( primary = BrandTint, background = LightBg, surface = LightSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + outline = LightOutline, ) private val DarkColors = darkColorScheme( diff --git a/app/src/main/java/com/computerization/outspire/designsystem/Type.kt b/app/src/main/java/com/computerization/outspire/designsystem/Type.kt index 7605e73..135a2e3 100644 --- a/app/src/main/java/com/computerization/outspire/designsystem/Type.kt +++ b/app/src/main/java/com/computerization/outspire/designsystem/Type.kt @@ -10,8 +10,8 @@ val OutspireTypography = Typography( displayLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, - fontSize = 48.sp, - lineHeight = 52.sp, + fontSize = 40.sp, + lineHeight = 44.sp, ), headlineMedium = TextStyle( fontFamily = FontFamily.Default, diff --git a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt index 4c47f66..7845cf1 100644 --- a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt @@ -12,6 +12,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button @@ -38,26 +42,36 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.computerization.outspire.data.model.DomainScore import com.computerization.outspire.designsystem.AppRadius import com.computerization.outspire.designsystem.AppSpace +import com.computerization.outspire.designsystem.OutspireScreen -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable fun AcademicScreen( viewModel: AcademicViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), - verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), - ) { - Text( - text = "Academic", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) + val pullRefreshState = rememberPullRefreshState( + refreshing = state.loading, + onRefresh = viewModel::refresh, + ) + OutspireScreen( + title = "Academic", + onRefresh = viewModel::refresh, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .pullRefresh(pullRefreshState), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { var expanded by remember { mutableStateOf(false) } val selectedLabel = state.yearOptions .firstOrNull { it.id == state.selectedYearId }?.name @@ -121,6 +135,16 @@ fun AcademicScreen( } } } + } + + PullRefreshIndicator( + refreshing = state.loading, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = AppSpace.xs), + ) + } } } diff --git a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicViewModel.kt b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicViewModel.kt index 8676014..05ac811 100644 --- a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicViewModel.kt +++ b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicViewModel.kt @@ -33,6 +33,16 @@ class AcademicViewModel @Inject constructor( fun retry() = loadYearsAndScores() + fun refresh() { + val yearId = _state.value.selectedYearId + if (yearId == null) { + loadYearsAndScores() + return + } + _state.value = _state.value.copy(loading = true, error = null) + loadScores(yearId) + } + private fun loadYearsAndScores() { _state.value = _state.value.copy(loading = true, error = null) viewModelScope.launch { diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt b/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt index d9686e2..1c926ff 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt @@ -5,9 +5,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -21,7 +23,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import com.computerization.outspire.designsystem.AppSpace +import com.computerization.outspire.designsystem.OutspireScreen +@OptIn(ExperimentalMaterialApi::class) @Composable fun CasScreen(viewModel: CasViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() @@ -34,69 +38,88 @@ fun CasScreen(viewModel: CasViewModel = hiltViewModel()) { } } - Box(Modifier.fillMaxSize()) { - Column( + val refreshing = when { + state.selectedGroup != null -> state.records is AsyncList.Loading || state.reflections is AsyncList.Loading + state.selectedTab == CasTab.MyClubs -> state.myClubs is AsyncList.Loading + state.selectedTab == CasTab.Browse -> state.browse.loading + else -> state.evaluation is AsyncValue.Loading + } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = viewModel::refresh, + ) + + OutspireScreen( + title = "CAS", + snackbarHostState = snackbarHostState, + onRefresh = viewModel::refresh, + ) { innerPadding -> + Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), - verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + .padding(innerPadding) + .pullRefresh(pullRefreshState), ) { - Text( - text = "CAS", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - - if (state.selectedGroup != null) { - ClubDetailScreen( - group = state.selectedGroup!!, - records = state.records, - reflections = state.reflections, - onBack = { viewModel.closeGroup() }, - onRetry = { viewModel.retryGroupDetail() }, - onAddRecord = { viewModel.openAddRecord() }, - onEditRecord = { viewModel.openEditRecord(it) }, - onDeleteRecord = { viewModel.deleteRecord(it) }, - onAddReflection = { viewModel.openAddReflection() }, - onEditReflection = { viewModel.openEditReflection(it) }, - onDeleteReflection = { viewModel.deleteReflection(it) }, - ) - } else { - val tabs = CasTab.values() - TabRow(selectedTabIndex = tabs.indexOf(state.selectedTab)) { - tabs.forEach { tab -> - Tab( - selected = state.selectedTab == tab, - onClick = { viewModel.selectTab(tab) }, - text = { Text(tab.label()) }, + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + if (state.selectedGroup != null) { + ClubDetailScreen( + group = state.selectedGroup!!, + records = state.records, + reflections = state.reflections, + onBack = { viewModel.closeGroup() }, + onRetry = { viewModel.retryGroupDetail() }, + onAddRecord = { viewModel.openAddRecord() }, + onEditRecord = { viewModel.openEditRecord(it) }, + onDeleteRecord = { viewModel.deleteRecord(it) }, + onAddReflection = { viewModel.openAddReflection() }, + onEditReflection = { viewModel.openEditReflection(it) }, + onDeleteReflection = { viewModel.deleteReflection(it) }, + ) + } else { + val tabs = CasTab.values() + TabRow(selectedTabIndex = tabs.indexOf(state.selectedTab)) { + tabs.forEach { tab -> + Tab( + selected = state.selectedTab == tab, + onClick = { viewModel.selectTab(tab) }, + text = { Text(tab.label()) }, + ) + } + } + when (state.selectedTab) { + CasTab.MyClubs -> MyClubsTab( + state = state.myClubs, + onRetry = { viewModel.retryMyClubs() }, + onOpen = { viewModel.openGroup(it) }, + ) + CasTab.Browse -> BrowseClubsTab( + state = state.browse, + joiningId = state.joiningId, + onLoadMore = { viewModel.loadNextBrowsePage() }, + onJoin = { viewModel.join(it) }, + onRetry = { viewModel.retryBrowse() }, + ) + CasTab.Evaluation -> EvaluationTab( + state = state.evaluation, + onRetry = { viewModel.retryEvaluation() }, ) } } - when (state.selectedTab) { - CasTab.MyClubs -> MyClubsTab( - state = state.myClubs, - onRetry = { viewModel.retryMyClubs() }, - onOpen = { viewModel.openGroup(it) }, - ) - CasTab.Browse -> BrowseClubsTab( - state = state.browse, - joiningId = state.joiningId, - onLoadMore = { viewModel.loadNextBrowsePage() }, - onJoin = { viewModel.join(it) }, - onRetry = { viewModel.retryBrowse() }, - ) - CasTab.Evaluation -> EvaluationTab( - state = state.evaluation, - onRetry = { viewModel.retryEvaluation() }, - ) - } } - } - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter), - ) { Snackbar(it) } + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = AppSpace.xs), + ) + } state.recordEditor?.let { editor -> RecordEditorDialog( diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt b/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt index 94e87c9..046f22f 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt @@ -44,6 +44,19 @@ class CasViewModel @Inject constructor( fun retryMyClubs() = loadMyClubs() fun retryEvaluation() = loadEvaluation() + fun refresh() { + val s = _state.value + if (s.selectedGroup != null) { + retryGroupDetail() + return + } + when (s.selectedTab) { + CasTab.MyClubs -> loadMyClubs() + CasTab.Browse -> retryBrowse() + CasTab.Evaluation -> loadEvaluation() + } + } + fun openGroup(group: DomainCasGroup) { _state.update { it.copy( diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/ClubDetailScreen.kt b/app/src/main/java/com/computerization/outspire/feature/cas/ClubDetailScreen.kt index 2526c43..cc36b4d 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/ClubDetailScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/ClubDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -31,8 +32,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn import com.computerization.outspire.data.model.DomainCasGroup import com.computerization.outspire.data.model.DomainRecord import com.computerization.outspire.data.model.DomainReflection @@ -62,12 +67,32 @@ fun ClubDetailScreen( verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), ) { Row( - Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpace.md, vertical = AppSpace.sm), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(group.name, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) - TextButton(onClick = onBack) { Text("Back") } + Text( + group.name, + style = MaterialTheme.typography.titleLarge.copy( + shadow = Shadow( + color = Color.Black.copy(alpha = 0.45f), + offset = Offset(0f, 2f), + blurRadius = 8f, + ), + ), + color = Color.White, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = onBack, + colors = ButtonDefaults.textButtonColors( + contentColor = Color.White, + ), + ) { + Text("Back") + } } IntroCard(group) @@ -82,9 +107,11 @@ fun ClubDetailScreen( } } - when (tab) { - DetailTab.Records -> RecordsList(records, onRetry, onEditRecord, onDeleteRecord) - DetailTab.Reflections -> ReflectionsList(reflections, onRetry, onEditReflection, onDeleteReflection) + Box(Modifier.weight(1f, fill = true)) { + when (tab) { + DetailTab.Records -> RecordsList(records, onRetry, onEditRecord, onDeleteRecord) + DetailTab.Reflections -> ReflectionsList(reflections, onRetry, onEditReflection, onDeleteReflection) + } } } @@ -102,7 +129,9 @@ fun ClubDetailScreen( @Composable private fun IntroCard(group: DomainCasGroup) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp), shape = RoundedCornerShape(AppRadius.card), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { @@ -124,10 +153,12 @@ private fun IntroCard(group: DomainCasGroup) { ) } if (group.description.isNotBlank()) { - Text( - group.description, + HtmlText( + html = group.description, + modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 220.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = Int.MAX_VALUE, ) } else { Text( @@ -281,4 +312,3 @@ private fun ReflectionRow(reflection: DomainReflection, onEdit: () -> Unit, onDe ) } } - diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/HtmlText.kt b/app/src/main/java/com/computerization/outspire/feature/cas/HtmlText.kt new file mode 100644 index 0000000..c9190dd --- /dev/null +++ b/app/src/main/java/com/computerization/outspire/feature/cas/HtmlText.kt @@ -0,0 +1,155 @@ +package com.computerization.outspire.feature.cas + +import android.graphics.Color as AndroidColor +import android.text.method.LinkMovementMethod +import android.text.util.Linkify +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.webkit.WebSettings +import android.webkit.WebView +import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat +import com.computerization.outspire.BuildConfig +import org.jsoup.Jsoup +import org.jsoup.parser.Parser + +@Composable +internal fun HtmlText( + html: String, + modifier: Modifier = Modifier, + style: TextStyle, + color: Color, + maxLines: Int = Int.MAX_VALUE, +) { + if (maxLines == Int.MAX_VALUE) { + HtmlWebView( + html = html, + modifier = modifier, + style = style, + color = color, + ) + return + } + + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + layoutParams = android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + WRAP_CONTENT, + ) + autoLinkMask = Linkify.WEB_URLS + movementMethod = LinkMovementMethod.getInstance() + } + }, + update = { view -> + val normalizedHtml = sanitizeHtmlForPreview(decodePossiblyEscapedHtml(html)) + view.text = HtmlCompat.fromHtml(normalizedHtml, HtmlCompat.FROM_HTML_MODE_COMPACT) + view.setTextColor(color.toArgb()) + view.textSize = style.fontSize.takeIf { it != TextStyle.Default.fontSize }?.value ?: 14.sp.value + view.maxLines = maxLines + view.ellipsize = if (maxLines == Int.MAX_VALUE) null else android.text.TextUtils.TruncateAt.END + }, + ) +} + +@Composable +private fun HtmlWebView( + html: String, + modifier: Modifier, + style: TextStyle, + color: Color, +) { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + settings.javaScriptEnabled = false + settings.domStorageEnabled = false + settings.loadsImagesAutomatically = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + setBackgroundColor(AndroidColor.TRANSPARENT) + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = false + } + }, + update = { view -> + val content = sanitizeHtmlForWebView(decodePossiblyEscapedHtml(html), style, color) + view.loadDataWithBaseURL( + "${BuildConfig.TSIMS_BASE_URL}/", + content, + "text/html", + "utf-8", + null, + ) + }, + ) +} + +private fun decodePossiblyEscapedHtml(input: String): String { + var output = input.trim() + repeat(3) { + val prev = output + output = output + .replace("\\u003C", "<") + .replace("\\u003E", ">") + .replace("\\u0026", "&") + .replace("\\/", "/") + .replace("\\\"", "\"") + .replace("\\r\\n", "\n") + .replace("\\n", "\n") + .replace("\\r", "") + output = Parser.unescapeEntities(output, false) + if (output.length >= 2 && output.first() == '"' && output.last() == '"') { + output = output.substring(1, output.length - 1) + } + if (output == prev) return@repeat + } + return output +} + +private fun sanitizeHtmlForPreview(input: String): String { + val cleanedInput = input + .replace(Regex("(?is)@font-face\\s*\\{.*?\\}"), "") + .trim() + val doc = Jsoup.parse(cleanedInput) + doc.select("style,script,head,meta,link").remove() + val bodyHtml = doc.body()?.html().orEmpty().trim() + if (bodyHtml.isNotEmpty()) return bodyHtml + return cleanedInput.replace(Regex("(?is)]*>.*?"), "").trim() +} + +private fun sanitizeHtmlForWebView(input: String, style: TextStyle, color: Color): String { + val source = input.ifBlank { "

" } + val doc = if (source.contains("$source") + } + doc.select("script").remove() + val head = doc.head() + if (head.selectFirst("meta[name=viewport]") == null) { + head.appendElement("meta") + .attr("name", "viewport") + .attr("content", "width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no") + } + val fontPx = (style.fontSize.takeIf { it != TextStyle.Default.fontSize }?.value ?: 14f).toInt().coerceAtLeast(12) + val colorHex = "#%06X".format(0xFFFFFF and color.toArgb()) + head.appendElement("style").appendText( + """ + html, body { margin: 0; padding: 0; background: transparent; color: $colorHex; font-size: ${fontPx}px; line-height: 1.55; } + img { max-width: 100%; height: auto; } + table { max-width: 100%; } + """.trimIndent() + ) + return doc.outerHtml() +} diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/MyClubsTab.kt b/app/src/main/java/com/computerization/outspire/feature/cas/MyClubsTab.kt index f53f638..eba8008 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/MyClubsTab.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/MyClubsTab.kt @@ -67,8 +67,8 @@ internal fun GroupCard( Text("Teacher · ${group.teacher}", style = MaterialTheme.typography.bodySmall) } if (group.description.isNotBlank()) { - Text( - group.description, + HtmlText( + html = group.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, diff --git a/app/src/main/java/com/computerization/outspire/feature/login/LoginScreen.kt b/app/src/main/java/com/computerization/outspire/feature/login/LoginScreen.kt index c04a253..437dd12 100644 --- a/app/src/main/java/com/computerization/outspire/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/login/LoginScreen.kt @@ -1,13 +1,17 @@ package com.computerization.outspire.feature.login import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -21,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.computerization.outspire.designsystem.AppRadius import com.computerization.outspire.designsystem.AppSpace @Composable @@ -34,70 +39,86 @@ fun LoginScreen( if (state.loggedIn) onLoggedIn() } - Column( + Box( modifier = Modifier .fillMaxSize() .padding(horizontal = AppSpace.lg), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + contentAlignment = Alignment.Center, ) { - Text( - text = "Outspire", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = "Sign in to TSIMS", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(32.dp)) - - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChange, - label = { Text("Username") }, - singleLine = true, + Column( modifier = Modifier.fillMaxWidth(), - ) + verticalArrangement = Arrangement.spacedBy(AppSpace.lg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Outspire", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Sign in to TSIMS", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Spacer(Modifier.height(12.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(AppSpace.cardPadding), + verticalArrangement = Arrangement.spacedBy(AppSpace.sm), + ) { + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChange, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChange, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - ) + OutlinedTextField( + value = state.password, + onValueChange = viewModel::onPasswordChange, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(4.dp)) - Button( - onClick = viewModel::submit, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.loading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text("Sign in") - } - } + Button( + onClick = viewModel::submit, + enabled = !state.loading, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.loading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Sign in") + } + } - state.errorMessage?.let { msg -> - Spacer(Modifier.height(12.dp)) - Text( - text = msg, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + state.errorMessage?.let { msg -> + Text( + text = msg, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } } } } diff --git a/app/src/main/java/com/computerization/outspire/feature/settings/SettingsScreen.kt b/app/src/main/java/com/computerization/outspire/feature/settings/SettingsScreen.kt index 0c4b93f..3de35cc 100644 --- a/app/src/main/java/com/computerization/outspire/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/settings/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.computerization.outspire.designsystem.AppRadius import com.computerization.outspire.designsystem.AppSpace +import com.computerization.outspire.designsystem.OutspireScreen @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -35,17 +36,14 @@ fun SettingsScreen( ) { val state by viewModel.state.collectAsState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), - verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), - ) { - Text( - text = "Settings", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) + OutspireScreen(title = "Settings") { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { Card( modifier = Modifier.fillMaxWidth(), @@ -58,16 +56,17 @@ fun SettingsScreen( modifier = Modifier.padding(AppSpace.cardPadding), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + val placeholder = "-" Text("Account", style = MaterialTheme.typography.titleMedium) - Text("Student ID · ${state.user?.studentId ?: "—"}") - Text("Username · ${state.user?.username ?: "—"}") + Text("Student ID - ${state.user?.studentId ?: placeholder}") + Text("Username - ${state.user?.username ?: placeholder}") } } var expanded by remember { mutableStateOf(false) } val selectedLabel = state.yearOptions .firstOrNull { it.id == state.currentYearId }?.name - ?: "—" + ?: "-" ExposedDropdownMenuBox( expanded = expanded, @@ -104,7 +103,8 @@ fun SettingsScreen( enabled = !state.loggingOut, modifier = Modifier.fillMaxWidth(), ) { - Text(if (state.loggingOut) "Signing out…" else "Logout") + Text(if (state.loggingOut) "Signing out..." else "Logout") + } } } } diff --git a/app/src/main/java/com/computerization/outspire/feature/today/TodayScreen.kt b/app/src/main/java/com/computerization/outspire/feature/today/TodayScreen.kt index b581bca..328bcef 100644 --- a/app/src/main/java/com/computerization/outspire/feature/today/TodayScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/today/TodayScreen.kt @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import com.computerization.outspire.designsystem.AppSpace +import com.computerization.outspire.designsystem.OutspireScreen import com.computerization.outspire.designsystem.staggeredEntry import com.computerization.outspire.feature.today.components.NoClassCard import com.computerization.outspire.feature.today.components.QuickLinksCard @@ -32,78 +35,101 @@ import com.computerization.outspire.feature.today.components.WeatherBadge import com.computerization.outspire.feature.today.components.WeekendCard import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterialApi::class) @Composable fun TodayScreen( onNavigate: (String) -> Unit = {}, viewModel: TodayViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() + val refreshing by viewModel.refreshing.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() var animateCards by remember { mutableStateOf(false) } LaunchedEffect(Unit) { animateCards = true } - Box(Modifier.fillMaxSize()) { - Column( + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = viewModel::refresh, + ) + + OutspireScreen( + title = "Today", + snackbarHostState = snackbarHostState, + onRefresh = viewModel::refresh, + ) { innerPadding -> + Box( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), - verticalArrangement = Arrangement.spacedBy(AppSpace.lg), + .padding(innerPadding) + .pullRefresh(pullRefreshState), ) { - Text( - text = "Today", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - WeatherBadge(modifier = Modifier.align(Alignment.Start)) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), + verticalArrangement = Arrangement.spacedBy(AppSpace.lg), + ) { + WeatherBadge(modifier = Modifier.align(Alignment.Start)) - when (val s = state) { - TodayUiState.Loading -> { - Text("Loading…", color = MaterialTheme.colorScheme.onBackground) - } - is TodayUiState.Weekday -> { - UnifiedScheduleCard( - dayName = s.dayName, - classes = s.classes, - activeIndex = s.activeIndex, - nowLocal = s.now, - modifier = Modifier.staggeredEntry(0, animateCards), - ) - } - is TodayUiState.DayDone -> { - if (s.isWeekend) { - WeekendCard(modifier = Modifier.staggeredEntry(0, animateCards)) - } else { - NoClassCard( - isDimmed = s.isAfterSchool, + when (val s = state) { + TodayUiState.Loading -> { + Text("Loading…", color = MaterialTheme.colorScheme.onBackground) + } + is TodayUiState.Weekday -> { + UnifiedScheduleCard( + dayName = s.dayName, + classes = s.classes, + activeIndex = s.activeIndex, + nowLocal = s.now, modifier = Modifier.staggeredEntry(0, animateCards), ) } + is TodayUiState.DayDone -> { + if (s.isWeekend) { + WeekendCard(modifier = Modifier.staggeredEntry(0, animateCards)) + } else { + NoClassCard( + isDimmed = s.isAfterSchool, + modifier = Modifier.staggeredEntry(0, animateCards), + ) + } + } + is TodayUiState.Error -> { + Column(verticalArrangement = Arrangement.spacedBy(AppSpace.sm)) { + Text( + text = "Couldn't load timetable: ${s.message}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = "Pull to refresh or tap the refresh icon.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } - is TodayUiState.Error -> { - Text( - text = "Couldn't load timetable: ${s.message}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } + + QuickLinksCard( + modifier = Modifier.staggeredEntry(1, animateCards), + onClubs = { onNavigate("cas") }, + onDining = { + scope.launch { snackbarHostState.showSnackbar("Dining · coming soon") } + }, + onActivities = { onNavigate("cas") }, + onReflect = { onNavigate("cas") }, + ) } - QuickLinksCard( - modifier = Modifier.staggeredEntry(1, animateCards), - onClubs = { onNavigate("cas") }, - onDining = { - scope.launch { snackbarHostState.showSnackbar("Dining · coming soon") } - }, - onActivities = { onNavigate("cas") }, - onReflect = { onNavigate("cas") }, + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = AppSpace.xs), ) } - - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter), - ) { Snackbar(it) } } } diff --git a/app/src/main/java/com/computerization/outspire/feature/today/TodayViewModel.kt b/app/src/main/java/com/computerization/outspire/feature/today/TodayViewModel.kt index ac35dd1..1a7f64b 100644 --- a/app/src/main/java/com/computerization/outspire/feature/today/TodayViewModel.kt +++ b/app/src/main/java/com/computerization/outspire/feature/today/TodayViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek @@ -29,6 +30,8 @@ class TodayViewModel @Inject constructor( private val classesFlow = MutableStateFlow?>(null) private val errorFlow = MutableStateFlow(null) + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = _refreshing.asStateFlow() init { load() @@ -56,10 +59,12 @@ class TodayViewModel @Inject constructor( private fun load() { viewModelScope.launch { + _refreshing.value = true if (BuildConfig.USE_MOCK_BACKEND) { classesFlow.value = MockClasstable.today.map { DomainClass(it.subject, it.teacher, it.room, it.start, it.end) } + _refreshing.value = false return@launch } repository.todayClasses() @@ -70,6 +75,7 @@ class TodayViewModel @Inject constructor( .onFailure { t -> errorFlow.value = t.message ?: "Failed to load timetable" } + _refreshing.value = false } } diff --git a/app/src/main/java/com/computerization/outspire/navigation/OutspireRoot.kt b/app/src/main/java/com/computerization/outspire/navigation/OutspireRoot.kt index 0d5062a..0aff43d 100644 --- a/app/src/main/java/com/computerization/outspire/navigation/OutspireRoot.kt +++ b/app/src/main/java/com/computerization/outspire/navigation/OutspireRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.compose.NavHost @@ -44,6 +45,7 @@ fun OutspireRoot( } Scaffold( + containerColor = Color.Transparent, bottomBar = { if (showBottomBar) { NavigationBar { 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 index 0c5e2de..33b4dd1 100644 --- a/app/src/test/java/com/computerization/outspire/data/repository/CasDtoTest.kt +++ b/app/src/test/java/com/computerization/outspire/data/repository/CasDtoTest.kt @@ -10,6 +10,7 @@ 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 @@ -61,7 +62,7 @@ class CasDtoTest { id = "1", groupId = "g", title = "Week 1", summary = "good", content = "

Hello world

", - outcome = 3, + outcome = JsonPrimitive(3), ) val d = dto.toDomain() assertEquals(LearningOutcome.INITIATIVE, d.outcome) @@ -71,7 +72,18 @@ class CasDtoTest { @Test fun `reflection with null outcome yields null enum`() { assertNull(ReflectionDto(outcome = null).toDomain().outcome) - assertNull(ReflectionDto(outcome = 99).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 diff --git a/gradle.properties b/gradle.properties index 3f6f2ce..b89bb35 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,5 @@ 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5fc3e8..f0f019d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph 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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2a84e18..0d311c1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.0.0/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 78d9508..6ef3239 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,13 @@ 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.*") @@ -7,21 +15,28 @@ pluginManagement { includeGroupByRegex("androidx.*") } } + maven { url = uri("https://maven.aliyun.com/repository/central") } mavenCentral() + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } gradlePluginPortal() } } -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" -} - 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 = "Outspire-Android" -include(":app") +rootProject.name = "My Application" +include(":app") \ No newline at end of file From 9ac6d16191cd97b0e4f416a13f15cd09523d2fcd Mon Sep 17 00:00:00 2001 From: EMEEEEMMMM Date: Fri, 24 Apr 2026 12:41:34 +0800 Subject: [PATCH 2/2] Polish academic and CAS UI, fix KSP plugin resolution --- .../outspire/designsystem/OutspireScreen.kt | 21 +- .../feature/academic/AcademicScreen.kt | 424 ++++++++++++++---- .../outspire/feature/cas/BrowseClubsTab.kt | 83 ++-- .../outspire/feature/cas/CasScreen.kt | 1 + .../outspire/feature/cas/CasUiState.kt | 13 + .../outspire/feature/cas/CasViewModel.kt | 13 + settings.gradle.kts | 19 +- 7 files changed, 446 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt b/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt index bd059f8..3b353ae 100644 --- a/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt +++ b/app/src/main/java/com/computerization/outspire/designsystem/OutspireScreen.kt @@ -3,6 +3,7 @@ package com.computerization.outspire.designsystem import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -12,7 +13,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -21,10 +21,14 @@ 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.clickable import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,16 +46,17 @@ fun OutspireScreen( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = AppSpace.md, vertical = AppSpace.sm), + .padding(horizontal = AppSpace.md) + .padding(top = 2.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, - style = MaterialTheme.typography.headlineMedium.copy( + style = MaterialTheme.typography.headlineSmall.copy( shadow = Shadow( color = Color.Black.copy(alpha = 0.4f), offset = Offset(0f, 2f), - blurRadius = 8f, + blurRadius = 6f, ), ), color = Color.White, @@ -60,7 +65,13 @@ fun OutspireScreen( modifier = Modifier.weight(1f), ) if (onRefresh != null) { - IconButton(onClick = onRefresh) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable(onClick = onRefresh), + contentAlignment = Alignment.Center, + ) { Icon( imageVector = Icons.Rounded.Refresh, contentDescription = "Refresh", diff --git a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt index 7845cf1..762755f 100644 --- a/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/academic/AcademicScreen.kt @@ -1,23 +1,25 @@ package com.computerization.outspire.feature.academic -import androidx.compose.foundation.horizontalScroll +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -26,8 +28,10 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -37,14 +41,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.computerization.outspire.data.model.DomainScore +import com.computerization.outspire.data.remote.dto.YearOption import com.computerization.outspire.designsystem.AppRadius import com.computerization.outspire.designsystem.AppSpace import com.computerization.outspire.designsystem.OutspireScreen +import com.computerization.outspire.designsystem.coloredRichCard -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun AcademicScreen( viewModel: AcademicViewModel = hiltViewModel(), @@ -66,76 +76,47 @@ fun AcademicScreen( .padding(innerPadding) .pullRefresh(pullRefreshState), ) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = state.yearOptions + .firstOrNull { it.id == state.selectedYearId }?.name + ?: "Select term" + Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = AppSpace.md, vertical = AppSpace.lg), + .padding(horizontal = AppSpace.md, vertical = AppSpace.xs), verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), ) { - var expanded by remember { mutableStateOf(false) } - val selectedLabel = state.yearOptions - .firstOrNull { it.id == state.selectedYearId }?.name - ?: "Select term" - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - ) { - OutlinedTextField( - value = selectedLabel, - onValueChange = {}, - readOnly = true, - label = { Text("Term") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - state.yearOptions.forEach { opt -> - DropdownMenuItem( - text = { Text(opt.name) }, - onClick = { - viewModel.selectYear(opt.id) - expanded = false - }, - ) - } - } - } + TermPickerCard( + selectedLabel = selectedLabel, + expanded = expanded, + yearOptions = state.yearOptions, + onExpandedChange = { expanded = !expanded }, + onDismissRequest = { expanded = false }, + onSelect = { + viewModel.selectYear(it) + expanded = false + }, + ) - when { - state.loading -> { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - state.error != null -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = state.error ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, + when { + state.loading -> LoadingCard() + state.error != null -> ErrorCard( + message = state.error ?: "", + onRetry = viewModel::retry, ) - Button(onClick = { viewModel.retry() }) { Text("Retry") } + state.scores.isEmpty() -> EmptyCard() + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + items(state.scores, key = { it.subject }) { score -> + ScoreCard(score) + } + } + } } } - state.scores.isEmpty() -> { - Text( - text = "No scores for this term", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - else -> { - LazyColumn(verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing)) { - items(state.scores, key = { it.subject }) { ScoreRow(it) } - } - } - } - } PullRefreshIndicator( refreshing = state.loading, @@ -148,44 +129,309 @@ fun AcademicScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ScoreRow(score: DomainScore) { +private fun TermPickerCard( + selectedLabel: String, + expanded: Boolean, + yearOptions: List, + onExpandedChange: () -> Unit, + onDismissRequest: () -> Unit, + onSelect: (String) -> Unit, +) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(AppRadius.card), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { - Column(Modifier.padding(AppSpace.cardPadding)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpace.md, vertical = AppSpace.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpace.sm), + ) { + Surface( + shape = RoundedCornerShape(AppRadius.sm), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(6.dp), + ) + } + Text( + text = "Term", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { onExpandedChange() }, + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + yearOptions.forEach { opt -> + DropdownMenuItem( + text = { Text(opt.name) }, + onClick = { onSelect(opt.id) }, + ) + } + } + } + } + } +} + +@Composable +private fun LoadingCard() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpace.xl), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } +} + +@Composable +private fun ErrorCard( + message: String, + onRetry: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(AppSpace.cardPadding), + verticalArrangement = Arrangement.spacedBy(AppSpace.sm), + ) { Text( - text = score.subject, + text = "Could not load scores", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onRetry) { Text("Retry") } + } + } +} + +@Composable +private fun EmptyCard() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.card), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(AppSpace.cardPadding), + verticalArrangement = Arrangement.spacedBy(AppSpace.xs), + ) { + Text( + text = "No scores for this term", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Try another term from the selector above.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ScoreCard(score: DomainScore) { + val palette = remember(score.subject) { subjectPalette(score.subject) } + + Column( + modifier = Modifier.coloredRichCard( + colors = listOf(palette.start, palette.end), + cornerRadius = AppRadius.card, + shadowRadius = 10.dp, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpace.cardPadding), + verticalArrangement = Arrangement.spacedBy(AppSpace.md), + ) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, ) { - score.terms.forEach { t -> - AssistChip( - onClick = {}, - enabled = false, - label = { - val ib = if (t.ib.isNotBlank()) " / ${t.ib}" else "" - Text("${t.label}: ${t.raw}$ib") - }, - colors = AssistChipDefaults.assistChipColors( - disabledContainerColor = MaterialTheme.colorScheme.surface, - disabledLabelColor = MaterialTheme.colorScheme.onSurface, - ), + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(AppSpace.xxs), + ) { + Text( + text = score.subject, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${score.terms.size} term entries", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.76f), + ) + } + Surface( + shape = RoundedCornerShape(AppRadius.lg), + color = Color.White.copy(alpha = 0.14f), + ) { + Row( + modifier = Modifier.padding(horizontal = AppSpace.sm, vertical = AppSpace.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpace.xs), + ) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = null, + tint = Color.White, + ) + Text( + text = palette.label, + style = MaterialTheme.typography.labelMedium, + color = Color.White, + ) + } + } + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(AppSpace.sm), + verticalArrangement = Arrangement.spacedBy(AppSpace.sm), + maxItemsInEachRow = 2, + ) { + score.terms.forEach { term -> + TermScoreTile( + term = term, + modifier = Modifier.weight(1f, fill = true), ) } } } } } + +@Composable +private fun TermScoreTile( + term: DomainScore.TermScore, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(AppRadius.lg), + color = Color.White.copy(alpha = 0.16f), + tonalElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AppRadius.lg)) + .background(Color.Black.copy(alpha = 0.08f)) + .padding(AppSpace.md), + verticalArrangement = Arrangement.spacedBy(AppSpace.sm), + ) { + Text( + text = term.label, + style = MaterialTheme.typography.labelLarge, + color = Color.White.copy(alpha = 0.84f), + ) + ScoreMetric( + label = "Raw", + value = term.raw.ifBlank { "-" }, + ) + ScoreMetric( + label = "IB", + value = term.ib.ifBlank { "-" }, + ) + } + } +} + +@Composable +private fun ScoreMetric( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.68f), + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } +} + +private data class SubjectPalette( + val start: Color, + val end: Color, + val label: String, +) + +private val SubjectPalettes = listOf( + SubjectPalette(Color(0xFF3A7BFF), Color(0xFF74B6FF), "Sky"), + SubjectPalette(Color(0xFF0F8C6C), Color(0xFF47C8A2), "Mint"), + SubjectPalette(Color(0xFF8B5CF6), Color(0xFFB794F4), "Iris"), + SubjectPalette(Color(0xFFE85D75), Color(0xFFFF9A7A), "Coral"), + SubjectPalette(Color(0xFF9A6C1F), Color(0xFFF2B94B), "Amber"), + SubjectPalette(Color(0xFF165D86), Color(0xFF4AB0D9), "Ocean"), + SubjectPalette(Color(0xFF5E4AE3), Color(0xFF8A7BFF), "Indigo"), + SubjectPalette(Color(0xFFB6476B), Color(0xFFFF8CB3), "Rose"), +) + +private fun subjectPalette(subject: String): SubjectPalette { + val index = (subject.lowercase().hashCode() and Int.MAX_VALUE) % SubjectPalettes.size + return SubjectPalettes[index] +} diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/BrowseClubsTab.kt b/app/src/main/java/com/computerization/outspire/feature/cas/BrowseClubsTab.kt index 2064a99..ebb06d0 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/BrowseClubsTab.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/BrowseClubsTab.kt @@ -2,6 +2,7 @@ package com.computerization.outspire.feature.cas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -11,6 +12,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,6 +30,7 @@ fun BrowseClubsTab( state: BrowseState, joiningId: String?, onLoadMore: () -> Unit, + onSearchQueryChange: (String) -> Unit, onJoin: (DomainCasGroup) -> Unit, onRetry: () -> Unit, ) { @@ -46,46 +49,70 @@ fun BrowseClubsTab( } val listState = rememberLazyListState() - val needMore = remember(state) { + val visibleItems = state.filteredItems + val needMore = remember(state, visibleItems.size) { derivedStateOf { val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - last >= state.items.size - 3 && state.hasMore && !state.loading + last >= visibleItems.size - 3 && state.hasMore && !state.loading } } - LaunchedEffect(listState, state.items.size, state.pageIndex) { + LaunchedEffect(listState, state.items.size, state.pageIndex, visibleItems.size) { snapshotFlow { needMore.value }.distinctUntilChanged().collect { trigger -> if (trigger) onLoadMore() } } - LazyColumn(state = listState, verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing)) { - items(state.items, key = { it.id }) { group -> - GroupCard( - group = group, - trailing = { - Button( - onClick = { onJoin(group) }, - enabled = joiningId == null, - ) { - Text(if (joiningId == group.id) "Joining…" else "Join") + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing), + ) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Search clubs") }, + placeholder = { Text("Search by club name or teacher") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + LazyColumn(state = listState, verticalArrangement = Arrangement.spacedBy(AppSpace.cardSpacing)) { + items(visibleItems, key = { it.id }) { group -> + GroupCard( + group = group, + trailing = { + Button( + onClick = { onJoin(group) }, + enabled = joiningId == null, + ) { + Text(if (joiningId == group.id) "Joining..." else "Join") + } + }, + ) + } + if (!state.loading && visibleItems.isEmpty()) { + item { + Text( + "No clubs match your search.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(AppSpace.md), + ) + } + } + if (state.loading) { + item { + Box(Modifier.fillMaxWidth().padding(AppSpace.md), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } - }, - ) - } - if (state.loading) { - item { - Box(Modifier.fillMaxWidth().padding(AppSpace.md), contentAlignment = Alignment.Center) { - CircularProgressIndicator() } } - } - if (state.error != null) { - item { - Text( - state.error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(AppSpace.md), - ) + if (state.error != null) { + item { + Text( + state.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(AppSpace.md), + ) + } } } } diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt b/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt index 1c926ff..d59ea96 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/CasScreen.kt @@ -101,6 +101,7 @@ fun CasScreen(viewModel: CasViewModel = hiltViewModel()) { state = state.browse, joiningId = state.joiningId, onLoadMore = { viewModel.loadNextBrowsePage() }, + onSearchQueryChange = { viewModel.updateBrowseQuery(it) }, onJoin = { viewModel.join(it) }, onRetry = { viewModel.retryBrowse() }, ) diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/CasUiState.kt b/app/src/main/java/com/computerization/outspire/feature/cas/CasUiState.kt index 0683ce9..08b877e 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/CasUiState.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/CasUiState.kt @@ -48,11 +48,24 @@ data class ReflectionEditorState( data class BrowseState( val items: List = emptyList(), + val searchQuery: String = "", val pageIndex: Int = 0, val pageCount: Int = 1, val loading: Boolean = false, val error: String? = null, ) { + val filteredItems: List + get() { + val query = searchQuery.trim() + if (query.isBlank()) return items + return items.filter { group -> + group.name.contains(query, ignoreCase = true) || + group.teacher.contains(query, ignoreCase = true) || + group.groupNo.contains(query, ignoreCase = true) || + group.description.contains(query, ignoreCase = true) + } + } + val hasMore: Boolean get() = pageIndex < pageCount } diff --git a/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt b/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt index 046f22f..6599cc8 100644 --- a/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt +++ b/app/src/main/java/com/computerization/outspire/feature/cas/CasViewModel.kt @@ -90,12 +90,17 @@ class CasViewModel @Inject constructor( it.copy( browse = BrowseState( items = current.items + page.items, + searchQuery = current.searchQuery, pageIndex = page.pageIndex, pageCount = page.pageCount, loading = false, ) ) } + val updated = _state.value.browse + if (updated.searchQuery.isNotBlank() && updated.hasMore && !updated.loading) { + loadNextBrowsePage() + } } .onFailure { t -> _state.update { @@ -115,6 +120,14 @@ class CasViewModel @Inject constructor( loadNextBrowsePage() } + fun updateBrowseQuery(query: String) { + _state.update { it.copy(browse = it.browse.copy(searchQuery = query)) } + val browse = _state.value.browse + if (query.isNotBlank() && browse.hasMore && !browse.loading) { + loadNextBrowsePage() + } + } + fun join(group: DomainCasGroup) { if (_state.value.joiningId != null) return _state.update { it.copy(joiningId = group.id) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ef3239..fb14fa1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,14 +1,23 @@ pluginManagement { + resolutionStrategy { + eachPlugin { + if (requested.id.id == "com.google.devtools.ksp") { + useModule("com.google.devtools.ksp:symbol-processing-gradle-plugin:${requested.version}") + } + } + } repositories { - maven { - url = uri("https://maven.aliyun.com/repository/google") + gradlePluginPortal() + google { content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") includeGroupByRegex("androidx.*") } } - google { + mavenCentral() + maven { + url = uri("https://maven.aliyun.com/repository/google") content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") @@ -16,9 +25,7 @@ pluginManagement { } } maven { url = uri("https://maven.aliyun.com/repository/central") } - mavenCentral() maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } - gradlePluginPortal() } } dependencyResolutionManagement { @@ -39,4 +46,4 @@ dependencyResolutionManagement { } rootProject.name = "My Application" -include(":app") \ No newline at end of file +include(":app")