Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +16,9 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
OutspireTheme {
OutspireRoot()
OutspireBackground {
OutspireRoot()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupDto> = authService.withAuthRetry {
val env: ApiEnvelope<List<GroupDto>> = client.post("/Stu/Cas/GetMyGroupList") {
Expand Down Expand Up @@ -65,16 +72,19 @@ class CasService @Inject constructor(
}

suspend fun getReflections(groupId: String): List<ReflectionDto> = authService.withAuthRetry {
val env: ApiEnvelope<PagedEnvelope<ReflectionDto>> =
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<PagedEnvelope<ReflectionDto>> = json.decodeFromString(
ApiEnvelope.serializer(PagedEnvelope.serializer(ReflectionDto.serializer())),
sanitizeReflectionJson(raw),
)
env.require("GetReflectionList")?.List.orEmpty()
}

Expand Down Expand Up @@ -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. "</\r\np>"), which is invalid JSON.
.replace("\r", "")
.replace("\n", "")

private fun io.ktor.client.request.HttpRequestBuilder.form(fields: Map<String, String>) {
contentType(ContentType.Application.FormUrlEncoded)
setBody(FormDataContent(Parameters.build { fields.forEach { (k, v) -> append(k, v) } }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Int> = emptyList(),
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -156,5 +161,15 @@ class CasRepository @Inject constructor(
.replace("&quot;", "\"")
.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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading