Grund: Compose-app med login mot gym-API:t, VS Code-task och CI-release
Some checks failed
release / build-release (push) Failing after 1m42s

- Kotlin + Jetpack Compose + Material 3, minSdk 31 (Android 12)
- GraphQL-klient (OkHttp) med login/refresh/validate/logout som webben
- Tokenlagring i DataStore, auto-refresh av JWT
- Signering via gitignorerad signing/ lokalt och Actions-secrets i CI
- VS Code build-task -> build/FitnessDroid.apk
- Gitea Actions: bygger APK på Pi5 (arm64, aapt2-override) och publicerar
  rullande latest-release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-23 11:59:21 +02:00
commit 777f627888
33 changed files with 1816 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".FitnessDroidApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.FitnessDroid">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,26 @@
package eu.brassepc.fitnessdroid
import android.app.Application
import android.content.Context
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlClient
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.TokenStore
/** Enkel manuell DI-container — appen är för liten för Hilt. */
class AppContainer(context: Context) {
val tokenStore = TokenStore(context)
val graphQlClient = GraphQlClient { tokenStore.apiUrl() }
val authRepository = AuthRepository(tokenStore, graphQlClient)
val gymApi = GymApi(graphQlClient, authRepository)
}
class FitnessDroidApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}

View File

@@ -0,0 +1,20 @@
package eu.brassepc.fitnessdroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import eu.brassepc.fitnessdroid.ui.FitnessDroidApp
import eu.brassepc.fitnessdroid.ui.theme.FitnessDroidTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
FitnessDroidTheme {
FitnessDroidApp()
}
}
}
}

View File

@@ -0,0 +1,179 @@
package eu.brassepc.fitnessdroid.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.time.Instant
import java.time.OffsetDateTime
sealed interface AuthState {
data object Restoring : AuthState
data object LoggedOut : AuthState
data class LoggedIn(val username: String) : AuthState
}
/**
* Samma inloggningsflöde som webbens gymTrackerAuth.js: login-mutation ger
* JWT + refresh-token + sessions-id; JWT:n förnyas automatiskt strax innan
* den går ut via refreshAccessToken (refresh-token roteras då).
*/
class AuthRepository(
private val store: TokenStore,
private val client: GraphQlClient,
) {
private val _state = MutableStateFlow<AuthState>(AuthState.Restoring)
val state: StateFlow<AuthState> = _state
private val refreshMutex = Mutex()
suspend fun login(username: String, password: String, apiUrl: String) {
store.saveApiUrl(apiUrl.trim())
val data = client.execute(
LOGIN,
buildJsonObject {
put("u", username.trim())
put("p", password)
},
)
val result = data["login"]?.jsonObject
?: throw GraphQlException(listOf("Oväntat svar vid inloggning"))
val success = result["success"]?.jsonPrimitive?.booleanOrNull ?: false
val token = result["token"]?.jsonPrimitive?.contentOrNullSafe()
val refreshToken = result["refreshToken"]?.jsonPrimitive?.contentOrNullSafe()
val sessionId = result["sessionId"]?.jsonPrimitive?.contentOrNullSafe()
val expiration = result["expiration"]?.jsonPrimitive?.contentOrNullSafe()
if (!success || token == null || refreshToken == null || sessionId == null) {
throw GraphQlException(listOf("Fel användarnamn eller lösenord"))
}
val userName = result["userName"]?.jsonPrimitive?.contentOrNullSafe() ?: username.trim()
store.saveSession(
username = userName,
sessionId = sessionId,
token = token,
refreshToken = refreshToken,
expirationEpochMs = parseExpiration(expiration),
)
_state.value = AuthState.LoggedIn(userName)
}
/** Återställ sessionen vid appstart, som webbens restoreSession(). */
suspend fun restoreSession() {
val saved = store.read()
if (saved.sessionId.isNullOrEmpty() || saved.refreshToken.isNullOrEmpty()) {
_state.value = AuthState.LoggedOut
return
}
try {
val data = client.execute(
VALIDATE_SESSION,
buildJsonObject { put("s", saved.sessionId) },
)
val valid = data["validateSession"]?.jsonObject
?.get("isValid")?.jsonPrimitive?.booleanOrNull == true
if (valid) {
_state.value = AuthState.LoggedIn(saved.username.orEmpty())
} else {
store.clearSession()
_state.value = AuthState.LoggedOut
}
} catch (e: Exception) {
// Nätverksfel vid uppstart: släpp in användaren om JWT:n inte hunnit
// gå ut, annars visa inloggningen igen.
val stillValid = (saved.expirationEpochMs ?: 0) > System.currentTimeMillis()
_state.value = if (stillValid) {
AuthState.LoggedIn(saved.username.orEmpty())
} else {
AuthState.LoggedOut
}
}
}
/**
* Ger en giltig JWT för Authorization-headern och förnyar den först om den
* går ut inom två minuter (samma marginal som webben).
*/
suspend fun bearerToken(): String = refreshMutex.withLock {
val saved = store.read()
val token = saved.token
?: throw GraphQlException(listOf("Inte inloggad"))
val expiresAt = saved.expirationEpochMs ?: 0
if (expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS) {
return token
}
val sessionId = saved.sessionId
val refreshToken = saved.refreshToken
if (sessionId == null || refreshToken == null) {
forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
}
try {
val data = client.execute(
REFRESH,
buildJsonObject {
put("s", sessionId)
put("r", refreshToken)
},
)
val result = data["refreshAccessToken"]?.jsonObject
val success = result?.get("success")?.jsonPrimitive?.booleanOrNull ?: false
val newToken = result?.get("accessToken")?.jsonPrimitive?.contentOrNullSafe()
val newRefresh = result?.get("newRefreshToken")?.jsonPrimitive?.contentOrNullSafe()
val newExpiration = result?.get("accessTokenExpiration")?.jsonPrimitive?.contentOrNullSafe()
if (!success || newToken == null || newRefresh == null) {
forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
}
store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration))
newToken
} catch (e: GraphQlException) {
forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
}
}
suspend fun logout() {
val saved = store.read()
if (!saved.sessionId.isNullOrEmpty()) {
runCatching {
client.execute(LOGOUT, buildJsonObject { put("s", saved.sessionId) })
}
}
forceLogout()
}
private suspend fun forceLogout() {
store.clearSession()
_state.value = AuthState.LoggedOut
}
private fun parseExpiration(value: String?): Long {
if (value == null) return System.currentTimeMillis() + DEFAULT_TOKEN_LIFETIME_MS
return runCatching { OffsetDateTime.parse(value).toInstant().toEpochMilli() }
.recoverCatching { Instant.parse(value).toEpochMilli() }
.getOrDefault(System.currentTimeMillis() + DEFAULT_TOKEN_LIFETIME_MS)
}
companion object {
private const val REFRESH_MARGIN_MS = 2 * 60 * 1000L
private const val DEFAULT_TOKEN_LIFETIME_MS = 2 * 60 * 60 * 1000L
private const val LOGIN =
"mutation(\$u:String!,\$p:String!){login(username:\$u,password:\$p){success token refreshToken sessionId expiration userName}}"
private const val REFRESH =
"mutation(\$s:String!,\$r:String!){refreshAccessToken(sessionId:\$s,refreshToken:\$r){success accessToken newRefreshToken accessTokenExpiration sessionExpiration message}}"
private const val LOGOUT =
"mutation(\$s:String!){logoutAsync(sessionId:\$s)}"
private const val VALIDATE_SESSION =
"query(\$s:String!){validateSession(sessionId:\$s){isValid requiresLogin message sessionExpiration}}"
}
}
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNullSafe(): String? =
if (this is kotlinx.serialization.json.JsonNull) null else content.takeIf { it.isNotEmpty() }

View File

@@ -0,0 +1,64 @@
package eu.brassepc.fitnessdroid.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class GraphQlException(val messages: List<String>) : Exception(messages.joinToString("; "))
/**
* Minimal GraphQL-klient över OkHttp. Samma request-form som webbklienten
* (gymTrackerApi.js): POST {query, variables} + "Authorization: Bearer <jwt>".
*/
class GraphQlClient(private val urlProvider: suspend () -> String) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val json = Json { ignoreUnknownKeys = true }
suspend fun execute(
query: String,
variables: JsonObject = buildJsonObject {},
token: String? = null,
): JsonObject = withContext(Dispatchers.IO) {
val body = buildJsonObject {
put("query", query)
put("variables", variables)
}
val request = Request.Builder()
.url(urlProvider())
.post(body.toString().toRequestBody("application/json".toMediaType()))
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))
root["errors"]?.jsonArray?.takeIf { it.isNotEmpty() }?.let { errors ->
throw GraphQlException(
errors.map {
it.jsonObject["message"]?.jsonPrimitive?.content ?: "Okänt fel"
}
)
}
root["data"]?.jsonObject
?: throw GraphQlException(listOf("HTTP ${response.code}: tomt svar från servern"))
}
}
}

View File

@@ -0,0 +1,31 @@
package eu.brassepc.fitnessdroid.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
@Serializable
data class Profile(
val username: String,
val displayName: String? = null,
val email: String? = null,
val bodyWeightKg: Double? = null,
)
/** Autentiserade anrop mot gym-API:t. Query-strängarna speglar gymTrackerApi.js. */
class GymApi(
private val client: GraphQlClient,
private val auth: AuthRepository,
) {
suspend fun myProfile(): Profile {
val data = client.execute(MY_PROFILE, token = auth.bearerToken())
val profile = data["myProfile"]?.jsonObject
?: throw GraphQlException(listOf("Kunde inte hämta profilen"))
return client.json.decodeFromJsonElement(profile)
}
companion object {
private const val MY_PROFILE =
"query{myProfile{username displayName email bodyWeightKg}}"
}
}

View File

@@ -0,0 +1,87 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
private val Context.authDataStore by preferencesDataStore(name = "auth")
/** Persistent lagring av inloggningsuppgifter, motsvarar webbens localStorage-nycklar. */
class TokenStore(private val context: Context) {
data class Saved(
val username: String?,
val sessionId: String?,
val token: String?,
val refreshToken: String?,
val expirationEpochMs: Long?,
val apiUrl: String?,
)
suspend fun read(): Saved {
val prefs = context.authDataStore.data.first()
return Saved(
username = prefs[KEY_USERNAME],
sessionId = prefs[KEY_SESSION_ID],
token = prefs[KEY_TOKEN],
refreshToken = prefs[KEY_REFRESH_TOKEN],
expirationEpochMs = prefs[KEY_EXPIRATION],
apiUrl = prefs[KEY_API_URL],
)
}
suspend fun apiUrl(): String = read().apiUrl ?: DEFAULT_API_URL
suspend fun saveApiUrl(url: String) {
context.authDataStore.edit { it[KEY_API_URL] = url }
}
suspend fun saveSession(
username: String,
sessionId: String,
token: String,
refreshToken: String,
expirationEpochMs: Long,
) {
context.authDataStore.edit {
it[KEY_USERNAME] = username
it[KEY_SESSION_ID] = sessionId
it[KEY_TOKEN] = token
it[KEY_REFRESH_TOKEN] = refreshToken
it[KEY_EXPIRATION] = expirationEpochMs
}
}
suspend fun updateTokens(token: String, refreshToken: String, expirationEpochMs: Long) {
context.authDataStore.edit {
it[KEY_TOKEN] = token
it[KEY_REFRESH_TOKEN] = refreshToken
it[KEY_EXPIRATION] = expirationEpochMs
}
}
/** Rensar sessionen men behåller vald API-url. */
suspend fun clearSession() {
context.authDataStore.edit {
it.remove(KEY_USERNAME)
it.remove(KEY_SESSION_ID)
it.remove(KEY_TOKEN)
it.remove(KEY_REFRESH_TOKEN)
it.remove(KEY_EXPIRATION)
}
}
companion object {
const val DEFAULT_API_URL = "https://gymapi.brasse-pc.eu/graphql"
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_SESSION_ID = stringPreferencesKey("session_id")
private val KEY_TOKEN = stringPreferencesKey("token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
private val KEY_API_URL = stringPreferencesKey("api_url")
}
}

View File

@@ -0,0 +1,31 @@
package eu.brassepc.fitnessdroid.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.FitnessDroidApplication
import eu.brassepc.fitnessdroid.data.AuthRepository
import kotlinx.coroutines.launch
class AppViewModel(private val auth: AuthRepository) : ViewModel() {
val authState = auth.state
private var restored = false
fun restoreSession() {
if (restored) return
restored = true
viewModelScope.launch { auth.restoreSession() }
}
}
fun CreationExtras.appContainer() =
(this[APPLICATION_KEY] as FitnessDroidApplication).container
val AppViewModelFactory: ViewModelProvider.Factory = viewModelFactory {
initializer { AppViewModel(appContainer().authRepository) }
}

View File

@@ -0,0 +1,41 @@
package eu.brassepc.fitnessdroid.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 eu.brassepc.fitnessdroid.data.AuthState
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.login.LoginScreen
@Composable
fun FitnessDroidApp(appViewModel: AppViewModel = viewModel(factory = AppViewModelFactory)) {
val authState by appViewModel.authState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
appViewModel.restoreSession()
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
when (authState) {
AuthState.Restoring -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
AuthState.LoggedOut -> LoginScreen()
is AuthState.LoggedIn -> HomeScreen()
}
}
}

View File

@@ -0,0 +1,120 @@
package eu.brassepc.fitnessdroid.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory)) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("FitnessDroid") },
actions = {
IconButton(onClick = viewModel::logout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logga ut",
)
}
},
)
},
) { padding ->
when (val state = uiState) {
is HomeUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
is HomeUiState.Error -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.error)
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 16.dp)) {
Text("Försök igen")
}
}
}
is HomeUiState.Ready -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"Hej ${state.profile.displayName ?: state.profile.username}!",
style = MaterialTheme.typography.headlineSmall,
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Profil", style = MaterialTheme.typography.titleMedium)
ProfileRow("Användarnamn", state.profile.username)
state.profile.email?.let { ProfileRow("E-post", it) }
state.profile.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
}
}
Text(
"Pass, loggning, statistik och PB kommer i nästa steg.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
label,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value)
}
}

View File

@@ -0,0 +1,60 @@
package eu.brassepc.fitnessdroid.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlException
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Error(val message: String) : HomeUiState
data class Ready(val profile: Profile) : HomeUiState
}
class HomeViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState
init {
load()
}
fun load() {
_uiState.value = HomeUiState.Loading
viewModelScope.launch {
try {
_uiState.value = HomeUiState.Ready(gymApi.myProfile())
} catch (e: GraphQlException) {
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel")
} catch (e: Exception) {
_uiState.value = HomeUiState.Error("Kunde inte nå servern")
}
}
}
fun logout() {
viewModelScope.launch { auth.logout() }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val container = appContainer()
HomeViewModel(container.gymApi, container.authRepository)
}
}
}
}

View File

@@ -0,0 +1,172 @@
package eu.brassepc.fitnessdroid.ui.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory)) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPassword by remember { mutableStateOf(false) }
var showAdvanced by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.FitnessCenter,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text("FitnessDroid", style = MaterialTheme.typography.headlineMedium)
Text(
"Logga in med ditt gym-konto",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Card(elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::onUsernameChange,
label = { Text("Användarnamn") },
singleLine = true,
enabled = !uiState.loading,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Lösenord") },
singleLine = true,
enabled = !uiState.loading,
visualTransformation = if (showPassword) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (showPassword) {
"Dölj lösenord"
} else {
"Visa lösenord"
},
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.login() }),
modifier = Modifier.fillMaxWidth(),
)
if (showAdvanced) {
OutlinedTextField(
value = uiState.apiUrl,
onValueChange = viewModel::onApiUrlChange,
label = { Text("API-url") },
singleLine = true,
enabled = !uiState.loading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
}
uiState.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = viewModel::login,
enabled = !uiState.loading &&
uiState.username.isNotBlank() &&
uiState.password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (uiState.loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Text("Logga in")
}
}
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(if (showAdvanced) "Dölj avancerat" else "Avancerat")
}
}
}
}
}

View File

@@ -0,0 +1,78 @@
package eu.brassepc.fitnessdroid.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlException
import eu.brassepc.fitnessdroid.data.TokenStore
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val username: String = "",
val password: String = "",
val apiUrl: String = TokenStore.DEFAULT_API_URL,
val loading: Boolean = false,
val error: String? = null,
)
class LoginViewModel(
private val auth: AuthRepository,
private val store: TokenStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState
init {
viewModelScope.launch {
val saved = store.read()
_uiState.update {
it.copy(
username = saved.username.orEmpty(),
apiUrl = saved.apiUrl ?: TokenStore.DEFAULT_API_URL,
)
}
}
}
fun onUsernameChange(value: String) = _uiState.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _uiState.update { it.copy(password = value, error = null) }
fun onApiUrlChange(value: String) = _uiState.update { it.copy(apiUrl = value, error = null) }
fun login() {
val state = _uiState.value
if (state.loading || state.username.isBlank() || state.password.isBlank()) return
_uiState.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
auth.login(state.username, state.password, state.apiUrl)
// Lyckad inloggning byter skärm via AuthState — ingen mer UI-state här.
} catch (e: GraphQlException) {
_uiState.update { it.copy(loading = false, error = e.message) }
} catch (e: Exception) {
_uiState.update {
it.copy(
loading = false,
error = "Kunde inte nå servern — kontrollera nätet och API-url:en",
)
}
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val container = appContainer()
LoginViewModel(container.authRepository, container.tokenStore)
}
}
}
}

View File

@@ -0,0 +1,46 @@
package eu.brassepc.fitnessdroid.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF81C784),
secondary = Color(0xFFA5D6A7),
tertiary = Color(0xFF80CBC4),
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF2E7D32),
secondary = Color(0xFF388E3C),
tertiary = Color(0xFF00695C),
)
@Composable
fun FitnessDroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Material You-färger från användarens bakgrund; finns alltid på Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Enkel hantel som app-ikon -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group>
<!-- skivstång -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,51h48v6h-48z" />
<!-- vänster vikter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M26,38h6v32h-6z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M18,42h6v24h-6z" />
<!-- höger vikter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M76,38h6v32h-6z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M84,42h6v24h-6z" />
</group>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B5E20</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">FitnessDroid</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FitnessDroid" parent="android:Theme.Material.NoActionBar">
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>