commit 006944e3338314ef4c564b777aa9cc0e9d5673a2 Author: Tom Hempel Date: Wed Jun 17 11:25:40 2026 +0200 initial upload of prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74e0901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Gradle +.gradle/ +build/ +**/build/ + +# Local configuration +local.properties + +# Android Studio / IntelliJ +*.iml +.idea/ +!.idea/codeStyles/ +!.idea/inspectionProfiles/ +!.idea/runConfigurations.xml +!.idea/runConfigurations/ + +# Kotlin +.kotlin/ + +# Built artifacts +*.apk +*.aab +*.ap_ +*.dex + +# Native / NDK +.externalNativeBuild/ +.cxx/ + +# Captures and profiling +/captures/ +*.hprof + +# OS files +.DS_Store +Thumbs.db + +# Signing keys (never commit) +*.jks +*.keystore +keystore.properties + +# Secrets +google-services.json +secrets.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d9f5a8d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.tomhempel.coordinatorapp" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.tomhempel.coordinatorapp" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.security.crypto) + implementation(libs.gson) + implementation(libs.okhttp) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/tomhempel/coordinatorapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/tomhempel/coordinatorapp/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..1fe2ff8 --- /dev/null +++ b/app/src/androidTest/java/com/tomhempel/coordinatorapp/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.tomhempel.coordinatorapp + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.tomhempel.coordinatorapp", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6530f78 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/MainActivity.kt b/app/src/main/java/com/tomhempel/coordinatorapp/MainActivity.kt new file mode 100644 index 0000000..ee7193b --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/MainActivity.kt @@ -0,0 +1,30 @@ +package com.tomhempel.coordinatorapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.tomhempel.coordinatorapp.ui.CoordinatorAppRoot +import com.tomhempel.coordinatorapp.ui.theme.CoordinatorAppTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + CoordinatorAppTheme { + Surface( + modifier = Modifier.fillMaxSize().safeDrawingPadding(), + color = MaterialTheme.colorScheme.background, + ) { + CoordinatorAppRoot() + } + } + } + } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/data/Models.kt b/app/src/main/java/com/tomhempel/coordinatorapp/data/Models.kt new file mode 100644 index 0000000..e8f72e2 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/data/Models.kt @@ -0,0 +1,21 @@ +package com.tomhempel.coordinatorapp.data + +data class LoginResult( + val token: String, + val user: String, + val role: String, + val mustChangePassword: Boolean, +) + +data class CounselorUser( + val userID: String, + val username: String, + val mustChangePassword: Boolean, + val supervisorUsername: String?, +) + +data class SessionInfo( + val valid: Boolean, + val user: String, + val role: String, +) diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiConfig.kt b/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiConfig.kt new file mode 100644 index 0000000..6490f04 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiConfig.kt @@ -0,0 +1,6 @@ +package com.tomhempel.coordinatorapp.network + +object ApiConfig { + const val BASE_URL = "http://49.13.157.44/nat-as-server/api" + const val CLIENT_HEADER = "coordinator" +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiException.kt b/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiException.kt new file mode 100644 index 0000000..94ba4c6 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/network/ApiException.kt @@ -0,0 +1,6 @@ +package com.tomhempel.coordinatorapp.network + +class ApiException( + override val message: String, + val code: String? = null, +) : Exception(message) diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/network/NatAsApiClient.kt b/app/src/main/java/com/tomhempel/coordinatorapp/network/NatAsApiClient.kt new file mode 100644 index 0000000..bd4a8d5 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/network/NatAsApiClient.kt @@ -0,0 +1,223 @@ +package com.tomhempel.coordinatorapp.network + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.tomhempel.coordinatorapp.data.CounselorUser +import com.tomhempel.coordinatorapp.data.LoginResult +import com.tomhempel.coordinatorapp.data.SessionInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +object NatAsApiClient { + private val jsonMediaType = "application/json; charset=UTF-8".toMediaType() + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("username", username) + .put("password", password) + .toString() + .toRequestBody(jsonMediaType) + + val request = Request.Builder() + .url("${ApiConfig.BASE_URL}/auth/login") + .header("X-QDB-Client", ApiConfig.CLIENT_HEADER) + .post(body) + .build() + + execute(request) { data -> + val token = data.get("token")?.asString + ?: throw ApiException("Login response missing token") + LoginResult( + token = token, + user = data.get("user")?.asString.orEmpty().ifBlank { username }, + role = data.get("role")?.asString.orEmpty(), + mustChangePassword = data.get("mustChangePassword")?.asBoolean ?: false, + ) + } + } + + suspend fun changePassword( + token: String, + username: String, + oldPassword: String, + newPassword: String, + ): LoginResult = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("username", username) + .put("old_password", oldPassword) + .put("new_password", newPassword) + .toString() + .toRequestBody(jsonMediaType) + + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/auth/change-password") + .post(body) + .build() + + execute(request) { data -> + val newToken = data.get("token")?.asString + ?: throw ApiException("Password change response missing token") + LoginResult( + token = newToken, + user = data.get("user")?.asString.orEmpty().ifBlank { username }, + role = data.get("role")?.asString.orEmpty(), + mustChangePassword = false, + ) + } + } + + suspend fun getSession(token: String): SessionInfo = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/session") + .get() + .build() + + execute(request) { data -> + SessionInfo( + valid = data.get("valid")?.asBoolean ?: false, + user = data.get("user")?.asString.orEmpty(), + role = data.get("role")?.asString.orEmpty(), + ) + } + } + + suspend fun listCounselors(token: String): List = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/users") + .get() + .build() + + execute(request) { data -> + val users = data.getAsJsonArray("users") ?: return@execute emptyList() + users.mapNotNull { element -> + val obj = element.asJsonObject + val role = obj.get("role")?.asString.orEmpty() + if (role != "coach") return@mapNotNull null + val userID = obj.get("userID")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + CounselorUser( + userID = userID, + username = obj.get("username")?.asString.orEmpty(), + mustChangePassword = (obj.get("mustChangePassword")?.asInt ?: 0) != 0, + supervisorUsername = obj.get("supervisorUsername")?.asString, + ) + }.sortedBy { it.username.lowercase() } + } + } + + suspend fun resetCounselorPassword( + token: String, + userID: String, + newPassword: String, + temporary: Boolean, + ): Boolean = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("userID", userID) + .put("password", newPassword) + .put("mustChangePassword", if (temporary) 1 else 0) + .toString() + .toRequestBody(jsonMediaType) + + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/users") + .patch(body) + .build() + + execute(request) { data -> + (data.get("mustChangePassword")?.asInt ?: if (temporary) 1 else 0) != 0 + } + } + + suspend fun revokeCounselorSessions(token: String, userID: String): Int = + withContext(Dispatchers.IO) { + val body = JSONObject() + .put("action", "revokeSessions") + .put("userID", userID) + .toString() + .toRequestBody(jsonMediaType) + + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/users") + .patch(body) + .build() + + execute(request) { data -> + data.get("revokedSessions")?.asInt ?: 0 + } + } + + suspend fun logout(token: String) = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${ApiConfig.BASE_URL}/logout") + .delete() + .build() + + execute(request) { } + } + + private fun authorizedRequest(token: String): Request.Builder = + Request.Builder() + .header("Authorization", "Bearer $token") + .header("X-QDB-Client", ApiConfig.CLIENT_HEADER) + .header("Content-Type", "application/json; charset=UTF-8") + + private inline fun execute(request: Request, parse: (JsonObject) -> T): T { + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + val root = parseEnvelope(response.code, bodyText) + val data = root.get("data") + return when { + data == null || data.isJsonNull -> parse(JsonObject()) + data.isJsonObject -> parse(data.asJsonObject) + else -> throw ApiException("Unexpected response data") + } + } + } + + private fun parseEnvelope(httpCode: Int, body: String?): JsonObject { + if (body.isNullOrBlank()) { + throw ApiException("Empty server response ($httpCode)") + } + val root = try { + JsonParser.parseString(body).asJsonObject + } catch (_: Exception) { + throw ApiException("Server response was not valid JSON ($httpCode)") + } + if (!root.has("ok")) { + throw errorFromElement(root.get("error"), httpCode) + } + if (!root.get("ok").asBoolean) { + throw errorFromElement(root.get("error"), httpCode) + } + return root + } + + private fun errorFromElement(errorEl: JsonElement?, httpCode: Int): ApiException { + if (errorEl == null || errorEl.isJsonNull) { + return ApiException("Unexpected server response ($httpCode)") + } + if (errorEl.isJsonObject) { + val o = errorEl.asJsonObject + return ApiException( + o.get("message")?.asString ?: "Server error ($httpCode)", + o.get("code")?.asString, + ) + } + if (errorEl.isJsonPrimitive) { + return ApiException(errorEl.asString) + } + return ApiException("Unexpected server response ($httpCode)") + } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/network/SessionStore.kt b/app/src/main/java/com/tomhempel/coordinatorapp/network/SessionStore.kt new file mode 100644 index 0000000..5cf7051 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/network/SessionStore.kt @@ -0,0 +1,42 @@ +package com.tomhempel.coordinatorapp.network + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +object SessionStore { + private const val PREF = "coordinator_secure_prefs" + private const val KEY_TOKEN = "token" + private const val KEY_USER = "user" + private const val KEY_ROLE = "role" + + private fun prefs(context: Context) = EncryptedSharedPreferences.create( + context, + PREF, + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun save(context: Context, token: String, username: String, role: String) { + prefs(context).edit() + .putString(KEY_TOKEN, token) + .putString(KEY_USER, username) + .putString(KEY_ROLE, role) + .apply() + } + + fun getToken(context: Context): String? = prefs(context).getString(KEY_TOKEN, null) + + fun getUsername(context: Context): String? = prefs(context).getString(KEY_USER, null) + + fun getRole(context: Context): String? = prefs(context).getString(KEY_ROLE, null) + + fun hasSession(context: Context): Boolean = !getToken(context).isNullOrBlank() + + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/CoordinatorAppRoot.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/CoordinatorAppRoot.kt new file mode 100644 index 0000000..57e4df9 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/CoordinatorAppRoot.kt @@ -0,0 +1,80 @@ +package com.tomhempel.coordinatorapp.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tomhempel.coordinatorapp.ui.counselors.SessionManagementScreen +import com.tomhempel.coordinatorapp.ui.home.HomeScreen +import com.tomhempel.coordinatorapp.ui.login.ChangePasswordScreen +import com.tomhempel.coordinatorapp.ui.login.LoginScreen +import com.tomhempel.coordinatorapp.viewmodel.AppScreen +import com.tomhempel.coordinatorapp.viewmodel.CoordinatorViewModel + +@Composable +fun CoordinatorAppRoot( + viewModel: CoordinatorViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when { + state.isLoading && state.screen == AppScreen.Login && state.username.isBlank() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + state.screen == AppScreen.ChangePassword -> { + ChangePasswordScreen( + username = state.pendingUsername, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + onSubmit = viewModel::changePassword, + ) + } + state.screen == AppScreen.Home -> { + HomeScreen( + username = state.username, + role = state.role, + onOpenSessionManagement = viewModel::openSessionManagement, + onLogout = viewModel::logout, + ) + } + state.screen == AppScreen.SessionManagement -> { + SessionManagementScreen( + username = state.username, + role = state.role, + counselors = state.counselors, + isRefreshing = state.isRefreshing, + resetTarget = state.resetTarget, + isResettingPassword = state.isResettingPassword, + resetError = state.resetError, + revokeTarget = state.revokeTarget, + isRevokingSessions = state.isRevokingSessions, + revokeError = state.revokeError, + snackbarMessage = state.snackbarMessage, + onRefresh = viewModel::refreshCounselors, + onBack = viewModel::navigateHome, + onLogout = viewModel::logout, + onResetPassword = viewModel::openResetPassword, + onDismissReset = viewModel::dismissResetPassword, + onConfirmReset = viewModel::resetPassword, + onRevokeSessions = viewModel::openRevokeSessions, + onDismissRevoke = viewModel::dismissRevokeSessions, + onConfirmRevoke = viewModel::confirmRevokeSessions, + onSnackbarShown = viewModel::clearSnackbar, + ) + } + else -> { + LoginScreen( + isLoading = state.isLoading, + errorMessage = state.errorMessage, + onLogin = viewModel::login, + ) + } + } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/components/CoordinatorChrome.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/components/CoordinatorChrome.kt new file mode 100644 index 0000000..4dc97ae --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/components/CoordinatorChrome.kt @@ -0,0 +1,179 @@ +package com.tomhempel.coordinatorapp.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun CoordinatorBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + scheme.background, + scheme.surfaceVariant, + scheme.background, + ), + ), + ), + ) { + content() + } +} + +@Composable +fun ResponsiveContent( + modifier: Modifier = Modifier, + maxContentWidth: Dp = 1120.dp, + horizontalPadding: Dp = 20.dp, + verticalPadding: Dp = 16.dp, + content: @Composable ColumnScope.() -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + val sidePadding = when { + maxWidth < 360.dp -> 16.dp + maxWidth < 600.dp -> horizontalPadding + else -> 28.dp + } + Column( + modifier = Modifier + .widthIn(max = maxContentWidth) + .fillMaxWidth() + .padding(horizontal = sidePadding, vertical = verticalPadding), + content = content, + ) + } +} + +@Composable +fun FeatureTile( + title: String, + description: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = scheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = scheme.outline, + shape = RoundedCornerShape(20.dp), + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(scheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = scheme.primary, + modifier = Modifier.size(24.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = scheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +fun AuthCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val scheme = MaterialTheme.colorScheme + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = scheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = scheme.outline, + shape = RoundedCornerShape(24.dp), + ) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) + } +} + +fun gridColumnCount(contentWidth: Dp): Int = when { + contentWidth < 520.dp -> 1 + contentWidth < 840.dp -> 2 + else -> 3 +} + +fun gridContentPadding(contentWidth: Dp): PaddingValues { + val horizontal = if (contentWidth < 360.dp) 16.dp else 20.dp + return PaddingValues(horizontal = horizontal, vertical = 12.dp) +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/counselors/CounselorsScreen.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/counselors/CounselorsScreen.kt new file mode 100644 index 0000000..7de4eaf --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/counselors/CounselorsScreen.kt @@ -0,0 +1,404 @@ +package com.tomhempel.coordinatorapp.ui.counselors + +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 +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.border +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.tomhempel.coordinatorapp.data.CounselorUser +import com.tomhempel.coordinatorapp.ui.components.CoordinatorBackground +import com.tomhempel.coordinatorapp.ui.components.gridColumnCount +import com.tomhempel.coordinatorapp.ui.components.gridContentPadding + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionManagementScreen( + username: String, + role: String, + counselors: List, + isRefreshing: Boolean, + resetTarget: CounselorUser?, + isResettingPassword: Boolean, + resetError: String?, + revokeTarget: CounselorUser?, + isRevokingSessions: Boolean, + revokeError: String?, + snackbarMessage: String?, + onRefresh: () -> Unit, + onBack: () -> Unit, + onLogout: () -> Unit, + onResetPassword: (CounselorUser) -> Unit, + onDismissReset: () -> Unit, + onConfirmReset: (newPassword: String, confirmPassword: String, temporary: Boolean) -> Unit, + onRevokeSessions: (CounselorUser) -> Unit, + onDismissRevoke: () -> Unit, + onConfirmRevoke: () -> Unit, + onSnackbarShown: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(snackbarMessage) { + val message = snackbarMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + onSnackbarShown() + } + + CoordinatorBackground(modifier = modifier) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = androidx.compose.ui.graphics.Color.Transparent, + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + title = { + Column { + Text("Session management", fontWeight = FontWeight.SemiBold) + Text( + text = "$username · ${formatRole(role)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + IconButton(onClick = onRefresh, enabled = !isRefreshing) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + IconButton(onClick = onLogout) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Log out") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + ), + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (counselors.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "No counselors yet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Assigned counselors will appear here.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val columns = gridColumnCount(maxWidth) + val contentPadding = gridContentPadding(maxWidth) + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items(counselors, key = { it.userID }) { counselor -> + CounselorCard( + counselor = counselor, + showSupervisor = role == "admin", + onResetPassword = { onResetPassword(counselor) }, + onRevokeSessions = { onRevokeSessions(counselor) }, + ) + } + } + } + } + } + } + } + + if (resetTarget != null) { + ResetPasswordDialog( + counselor = resetTarget, + isLoading = isResettingPassword, + errorMessage = resetError, + onDismiss = onDismissReset, + onConfirm = onConfirmReset, + ) + } + + if (revokeTarget != null) { + RevokeSessionsDialog( + counselor = revokeTarget, + isLoading = isRevokingSessions, + errorMessage = revokeError, + onDismiss = onDismissRevoke, + onConfirm = onConfirmRevoke, + ) + } +} + +@Composable +private fun CounselorCard( + counselor: CounselorUser, + showSupervisor: Boolean, + onResetPassword: () -> Unit, + onRevokeSessions: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = scheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = scheme.outline, + shape = RoundedCornerShape(18.dp), + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = counselor.username, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (showSupervisor && !counselor.supervisorUsername.isNullOrBlank()) { + Text( + text = "Supervisor: ${counselor.supervisorUsername}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (counselor.mustChangePassword) { + Text( + text = "Must change password on next sign-in", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + Button( + onClick = onResetPassword, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Icon( + Icons.Default.LockReset, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text("Set temporary password") + } + OutlinedButton( + onClick = onRevokeSessions, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text("Sign out everywhere") + } + } + } +} + +@Composable +private fun ResetPasswordDialog( + counselor: CounselorUser, + isLoading: Boolean, + errorMessage: String?, + onDismiss: () -> Unit, + onConfirm: (newPassword: String, confirmPassword: String, temporary: Boolean) -> Unit, +) { + var newPassword by rememberSaveable(counselor.userID) { mutableStateOf("") } + var confirmPassword by rememberSaveable(counselor.userID) { mutableStateOf("") } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { Text("Reset temporary password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Set a new temporary password for ${counselor.username}. They must change it when signing in to the field app.", + style = MaterialTheme.typography.bodyMedium, + ) + + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("New password") }, + singleLine = true, + enabled = !isLoading, + visualTransformation = PasswordVisualTransformation(), + ) + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Confirm password") }, + singleLine = true, + enabled = !isLoading, + visualTransformation = PasswordVisualTransformation(), + ) + + if (!errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + Button( + onClick = { onConfirm(newPassword, confirmPassword, true) }, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(horizontal = 12.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Set password") + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isLoading) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun RevokeSessionsDialog( + counselor: CounselorUser, + isLoading: Boolean, + errorMessage: String?, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { Text("Sign out everywhere") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "End all active sessions for ${counselor.username}? They will need to sign in again on every device.", + style = MaterialTheme.typography.bodyMedium, + ) + if (!errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + Button( + onClick = onConfirm, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(horizontal = 12.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Sign out") + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isLoading) { + Text("Cancel") + } + }, + ) +} + +private fun formatRole(role: String): String = when (role) { + "admin" -> "Admin" + "supervisor" -> "Supervisor" + else -> role.replaceFirstChar { it.uppercase() } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/home/HomeScreen.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/home/HomeScreen.kt new file mode 100644 index 0000000..abab68e --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/home/HomeScreen.kt @@ -0,0 +1,153 @@ +package com.tomhempel.coordinatorapp.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.ManageAccounts +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tomhempel.coordinatorapp.ui.components.CoordinatorBackground +import com.tomhempel.coordinatorapp.ui.components.FeatureTile +import com.tomhempel.coordinatorapp.ui.components.gridColumnCount +import com.tomhempel.coordinatorapp.ui.components.gridContentPadding + +private data class HomeFeature( + val id: HomeFeatureId, + val title: String, + val description: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, +) + +private enum class HomeFeatureId { + SessionManagement, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + username: String, + role: String, + onOpenSessionManagement: () -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier, +) { + val features = listOf( + HomeFeature( + id = HomeFeatureId.SessionManagement, + title = "Session management", + description = "Reset counselor passwords and sign them out on all devices.", + icon = Icons.Default.ManageAccounts, + ), + ) + + CoordinatorBackground(modifier = modifier) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = androidx.compose.ui.graphics.Color.Transparent, + topBar = { + TopAppBar( + title = { + Column { + Text( + text = "NAT-AS Coordinator", + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "$username · ${formatRole(role)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + IconButton(onClick = onLogout) { + Icon( + Icons.AutoMirrored.Filled.Logout, + contentDescription = "Log out", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + ), + ) + }, + ) { padding -> + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + val columns = gridColumnCount(maxWidth) + val contentPadding = gridContentPadding(maxWidth) + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item(span = { GridItemSpan(columns) }) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "Welcome back", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Choose a task to get started.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + items(features, key = { it.id }) { feature -> + FeatureTile( + title = feature.title, + description = feature.description, + icon = feature.icon, + onClick = { + when (feature.id) { + HomeFeatureId.SessionManagement -> onOpenSessionManagement() + } + }, + ) + } + } + } + } + } +} + +private fun formatRole(role: String): String = when (role) { + "admin" -> "Admin" + "supervisor" -> "Supervisor" + else -> role.replaceFirstChar { it.uppercase() } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/login/LoginScreen.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/login/LoginScreen.kt new file mode 100644 index 0000000..8965b00 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/login/LoginScreen.kt @@ -0,0 +1,252 @@ +package com.tomhempel.coordinatorapp.ui.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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.tomhempel.coordinatorapp.ui.components.AuthCard +import com.tomhempel.coordinatorapp.ui.components.CoordinatorBackground +import com.tomhempel.coordinatorapp.ui.components.ResponsiveContent + +@Composable +fun LoginScreen( + isLoading: Boolean, + errorMessage: String?, + onLogin: (username: String, password: String) -> Unit, + modifier: Modifier = Modifier, +) { + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + CoordinatorBackground(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center, + ) { + ResponsiveContent( + maxContentWidth = 440.dp, + verticalPadding = 24.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "NAT-AS Coordinator", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Supervisor and admin tools", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 28.dp), + ) + + AuthCard { + Text( + text = "Sign in", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Use your coordinator account", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Username") }, + singleLine = true, + enabled = !isLoading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Password") }, + singleLine = true, + enabled = !isLoading, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (!isLoading) onLogin(username.trim(), password) + }, + ), + ) + + if (!errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Button( + onClick = { onLogin(username.trim(), password) }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = !isLoading, + shape = RoundedCornerShape(14.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(22.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Log in") + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Counselors sign in through the field app.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +fun ChangePasswordScreen( + username: String, + isLoading: Boolean, + errorMessage: String?, + onSubmit: (newPassword: String, confirmPassword: String) -> Unit, + modifier: Modifier = Modifier, +) { + var newPassword by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + + CoordinatorBackground(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center, + ) { + ResponsiveContent( + maxContentWidth = 440.dp, + verticalPadding = 24.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + AuthCard { + Text( + text = "Choose a new password", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = username, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("New password") }, + singleLine = true, + enabled = !isLoading, + visualTransformation = PasswordVisualTransformation(), + ) + + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Confirm password") }, + singleLine = true, + enabled = !isLoading, + visualTransformation = PasswordVisualTransformation(), + ) + + if (!errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Button( + onClick = { onSubmit(newPassword, confirmPassword) }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = !isLoading, + shape = RoundedCornerShape(14.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(22.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Save and continue") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Color.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Color.kt new file mode 100644 index 0000000..fe1be89 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Color.kt @@ -0,0 +1,21 @@ +package com.tomhempel.coordinatorapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val BrandPurple = Color(0xFF6E56CF) +val BrandPurpleDark = Color(0xFF9B8AE8) + +val BrandWindowBg = Color(0xFFF4F8F7) +val BrandSurface = Color(0xFFFFFFFF) +val BrandTextDark = Color(0xFF1F3A37) +val BrandTextMuted = Color(0xFF6E8480) +val BrandStroke = Color(0xFFD5E4E0) +val BrandDestructive = Color(0xFFC62828) +val BrandAccentContainer = Color(0xFFE6F4F0) + +val BrandWindowBgDark = Color(0xFF121A19) +val BrandSurfaceDark = Color(0xFF1E2A28) +val BrandTextLight = Color(0xFFE8F2F0) +val BrandTextMutedDark = Color(0xFF8AA39E) +val BrandStrokeDark = Color(0xFF2D403C) +val BrandAccentContainerDark = Color(0xFF243532) diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Theme.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Theme.kt new file mode 100644 index 0000000..90d767a --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Theme.kt @@ -0,0 +1,72 @@ +package com.tomhempel.coordinatorapp.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = BrandPurple, + onPrimary = Color.White, + primaryContainer = BrandAccentContainer, + onPrimaryContainer = BrandTextDark, + background = BrandWindowBg, + onBackground = BrandTextDark, + surface = BrandSurface, + onSurface = BrandTextDark, + surfaceVariant = BrandAccentContainer, + onSurfaceVariant = BrandTextMuted, + outline = BrandStroke, + error = BrandDestructive, + onError = Color.White, +) + +private val DarkColorScheme = darkColorScheme( + primary = BrandPurpleDark, + onPrimary = Color(0xFF1F163D), + primaryContainer = BrandAccentContainerDark, + onPrimaryContainer = BrandTextLight, + background = BrandWindowBgDark, + onBackground = BrandTextLight, + surface = BrandSurfaceDark, + onSurface = BrandTextLight, + surfaceVariant = BrandAccentContainerDark, + onSurfaceVariant = BrandTextMutedDark, + outline = BrandStrokeDark, + error = BrandDestructive, + onError = Color.White, +) + +@Composable +fun CoordinatorAppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Type.kt b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Type.kt new file mode 100644 index 0000000..e193104 --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.tomhempel.coordinatorapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/coordinatorapp/viewmodel/CoordinatorViewModel.kt b/app/src/main/java/com/tomhempel/coordinatorapp/viewmodel/CoordinatorViewModel.kt new file mode 100644 index 0000000..8b30b3a --- /dev/null +++ b/app/src/main/java/com/tomhempel/coordinatorapp/viewmodel/CoordinatorViewModel.kt @@ -0,0 +1,346 @@ +package com.tomhempel.coordinatorapp.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.tomhempel.coordinatorapp.data.CounselorUser +import com.tomhempel.coordinatorapp.network.ApiException +import com.tomhempel.coordinatorapp.network.NatAsApiClient +import com.tomhempel.coordinatorapp.network.SessionStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class CoordinatorViewModel(application: Application) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(CoordinatorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val allowedRoles = setOf("admin", "supervisor") + + init { + restoreSession() + } + + private fun restoreSession() { + val context = getApplication() + val token = SessionStore.getToken(context) ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + try { + val session = NatAsApiClient.getSession(token) + if (!session.valid || session.role !in allowedRoles) { + SessionStore.clear(context) + _uiState.update { CoordinatorUiState() } + return@launch + } + val counselors = NatAsApiClient.listCounselors(token) + _uiState.update { + CoordinatorUiState( + screen = AppScreen.Home, + username = session.user, + role = session.role, + counselors = counselors, + ) + } + } catch (_: Exception) { + SessionStore.clear(context) + _uiState.update { CoordinatorUiState() } + } + } + } + + fun login(username: String, password: String) { + val trimmedUser = username.trim() + if (trimmedUser.isBlank() || password.isBlank()) { + _uiState.update { it.copy(errorMessage = "Username and password are required") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + try { + val result = NatAsApiClient.login(trimmedUser, password) + if (result.role !in allowedRoles) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Only supervisor or admin accounts can use this app.", + ) + } + return@launch + } + if (result.mustChangePassword) { + _uiState.update { + it.copy( + isLoading = false, + screen = AppScreen.ChangePassword, + pendingUsername = trimmedUser, + pendingOldPassword = password, + pendingTempToken = result.token, + errorMessage = null, + ) + } + return@launch + } + completeLogin(result.token, result.user, result.role) + } catch (e: ApiException) { + _uiState.update { it.copy(isLoading = false, errorMessage = e.message) } + } catch (e: Exception) { + _uiState.update { + it.copy(isLoading = false, errorMessage = e.message ?: "Login failed") + } + } + } + } + + fun changePassword(newPassword: String, confirmPassword: String) { + val state = _uiState.value + val token = state.pendingTempToken + val username = state.pendingUsername + val oldPassword = state.pendingOldPassword + if (token.isBlank() || username.isBlank()) { + _uiState.update { it.copy(errorMessage = "Session expired. Please sign in again.") } + return + } + if (newPassword.length < 6) { + _uiState.update { it.copy(errorMessage = "Password must be at least 6 characters") } + return + } + if (newPassword != confirmPassword) { + _uiState.update { it.copy(errorMessage = "Passwords do not match") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + try { + val result = NatAsApiClient.changePassword(token, username, oldPassword, newPassword) + if (result.role !in allowedRoles) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Only supervisor or admin accounts can use this app.", + ) + } + return@launch + } + completeLogin(result.token, result.user, result.role) + } catch (e: ApiException) { + _uiState.update { it.copy(isLoading = false, errorMessage = e.message) } + } catch (e: Exception) { + _uiState.update { + it.copy(isLoading = false, errorMessage = e.message ?: "Password change failed") + } + } + } + } + + fun openSessionManagement() { + _uiState.update { it.copy(screen = AppScreen.SessionManagement, errorMessage = null) } + if (_uiState.value.counselors.isEmpty()) { + refreshCounselors() + } + } + + fun navigateHome() { + _uiState.update { + it.copy( + screen = AppScreen.Home, + resetTarget = null, + resetError = null, + revokeTarget = null, + revokeError = null, + errorMessage = null, + ) + } + } + + fun refreshCounselors() { + val context = getApplication() + val token = SessionStore.getToken(context) ?: return + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true, errorMessage = null) } + try { + val counselors = NatAsApiClient.listCounselors(token) + _uiState.update { it.copy(counselors = counselors, isRefreshing = false) } + } catch (e: ApiException) { + _uiState.update { it.copy(isRefreshing = false, errorMessage = e.message) } + } catch (e: Exception) { + _uiState.update { + it.copy(isRefreshing = false, errorMessage = e.message ?: "Could not load counselors") + } + } + } + } + + fun openResetPassword(counselor: CounselorUser) { + _uiState.update { + it.copy( + resetTarget = counselor, + resetError = null, + ) + } + } + + fun dismissResetPassword() { + _uiState.update { it.copy(resetTarget = null, resetError = null) } + } + + fun resetPassword(newPassword: String, confirmPassword: String, temporary: Boolean) { + val target = _uiState.value.resetTarget ?: return + val context = getApplication() + val token = SessionStore.getToken(context) ?: return + if (newPassword.length < 6) { + _uiState.update { it.copy(resetError = "Password must be at least 6 characters") } + return + } + if (newPassword != confirmPassword) { + _uiState.update { it.copy(resetError = "Passwords do not match") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isResettingPassword = true, resetError = null) } + try { + val mustChange = NatAsApiClient.resetCounselorPassword( + token = token, + userID = target.userID, + newPassword = newPassword, + temporary = temporary, + ) + val updated = _uiState.value.counselors.map { counselor -> + if (counselor.userID == target.userID) { + counselor.copy(mustChangePassword = mustChange) + } else { + counselor + } + } + _uiState.update { + it.copy( + counselors = updated, + resetTarget = null, + isResettingPassword = false, + snackbarMessage = "Temporary password set for ${target.username}", + ) + } + } catch (e: ApiException) { + _uiState.update { it.copy(isResettingPassword = false, resetError = e.message) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isResettingPassword = false, + resetError = e.message ?: "Could not reset password", + ) + } + } + } + } + + fun openRevokeSessions(counselor: CounselorUser) { + _uiState.update { + it.copy( + revokeTarget = counselor, + revokeError = null, + ) + } + } + + fun dismissRevokeSessions() { + _uiState.update { it.copy(revokeTarget = null, revokeError = null) } + } + + fun confirmRevokeSessions() { + val target = _uiState.value.revokeTarget ?: return + val context = getApplication() + val token = SessionStore.getToken(context) ?: return + viewModelScope.launch { + _uiState.update { it.copy(isRevokingSessions = true, revokeError = null) } + try { + val count = NatAsApiClient.revokeCounselorSessions(token, target.userID) + _uiState.update { + it.copy( + revokeTarget = null, + isRevokingSessions = false, + snackbarMessage = if (count == 1) { + "Signed out ${target.username} (1 session ended)" + } else { + "Signed out ${target.username} ($count sessions ended)" + }, + ) + } + } catch (e: ApiException) { + _uiState.update { it.copy(isRevokingSessions = false, revokeError = e.message) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isRevokingSessions = false, + revokeError = e.message ?: "Could not end sessions", + ) + } + } + } + } + + fun clearSnackbar() { + _uiState.update { it.copy(snackbarMessage = null) } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun logout() { + val context = getApplication() + val token = SessionStore.getToken(context) + viewModelScope.launch { + if (!token.isNullOrBlank()) { + try { + NatAsApiClient.logout(token) + } catch (_: Exception) { + // Clear local session even if the server is unreachable. + } + } + SessionStore.clear(context) + _uiState.value = CoordinatorUiState() + } + } + + private suspend fun completeLogin(token: String, username: String, role: String) { + val context = getApplication() + SessionStore.save(context, token, username, role) + val counselors = NatAsApiClient.listCounselors(token) + _uiState.value = CoordinatorUiState( + screen = AppScreen.Home, + username = username, + role = role, + counselors = counselors, + ) + } +} + +enum class AppScreen { + Login, + ChangePassword, + Home, + SessionManagement, +} + +data class CoordinatorUiState( + val screen: AppScreen = AppScreen.Login, + val username: String = "", + val role: String = "", + val counselors: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val errorMessage: String? = null, + val pendingUsername: String = "", + val pendingOldPassword: String = "", + val pendingTempToken: String = "", + val resetTarget: CounselorUser? = null, + val isResettingPassword: Boolean = false, + val resetError: String? = null, + val revokeTarget: CounselorUser? = null, + val isRevokingSessions: Boolean = false, + val revokeError: String? = null, + val snackbarMessage: String? = null, +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..912869d --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,5 @@ + + + +